refactor(chat): implement turn cancellation and status management in new chat routes for improved user experience and error handling

This commit is contained in:
Anish Sarkar 2026-05-01 01:47:52 +05:30
parent 4056bd1d69
commit af66fbf106
12 changed files with 671 additions and 81 deletions

View file

@ -147,6 +147,22 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
};
}
if (
errorCode === "TURN_CANCELLING"
) {
return {
kind: "thread_busy",
channel: "toast",
severity: "info",
telemetryEvent: "chat_blocked",
isExpected: true,
userMessage: "A previous response is still stopping. Please try again in a moment.",
rawMessage,
errorCode: errorCode ?? "TURN_CANCELLING",
details: { flow: input.flow },
};
}
if (
errorCode === "THREAD_BUSY"
) {
@ -156,7 +172,7 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
severity: "warn",
telemetryEvent: "chat_blocked",
isExpected: true,
userMessage: "A previous response is still stopping. Please try again in a moment.",
userMessage: "Another response is still finishing for this thread. Please try again in a moment.",
rawMessage,
errorCode: errorCode ?? "THREAD_BUSY",
details: { flow: input.flow },

View file

@ -1,6 +1,6 @@
export async function toHttpResponseError(
response: Response
): Promise<Error & { errorCode?: string }> {
): Promise<Error & { errorCode?: string; retryAfterMs?: number }> {
const statusDefaultCode =
response.status === 409
? "THREAD_BUSY"
@ -52,13 +52,37 @@ export async function toHttpResponseError(
: undefined;
const errorCode = detailCode ?? topLevelCode ?? statusDefaultCode;
const detailRetryAfterMs =
typeof detailObject?.retry_after_ms === "number"
? detailObject.retry_after_ms
: typeof detailObject?.retryAfterMs === "number"
? detailObject.retryAfterMs
: undefined;
const topRetryAfterMs =
typeof parsedBody?.retry_after_ms === "number"
? parsedBody.retry_after_ms
: typeof parsedBody?.retryAfterMs === "number"
? parsedBody.retryAfterMs
: undefined;
const headerRetryAfterMsRaw = response.headers.get("retry-after-ms");
const headerRetryAfterMs = headerRetryAfterMsRaw ? Number.parseFloat(headerRetryAfterMsRaw) : NaN;
const retryAfterHeader = response.headers.get("retry-after");
const retryAfterSeconds = retryAfterHeader ? Number.parseFloat(retryAfterHeader) : NaN;
const retryAfterMsFromHeader = Number.isFinite(headerRetryAfterMs)
? Math.max(0, Math.round(headerRetryAfterMs))
: Number.isFinite(retryAfterSeconds)
? Math.max(0, Math.round(retryAfterSeconds * 1000))
: undefined;
const retryAfterMs =
detailRetryAfterMs ?? topRetryAfterMs ?? retryAfterMsFromHeader ?? undefined;
const message =
detailNestedMessage ??
detailMessage ??
topLevelMessage ??
`Backend error: ${response.status}`;
return Object.assign(new Error(message), { errorCode });
return Object.assign(new Error(message), { errorCode, retryAfterMs });
}
export function tagPreAcceptSendFailure(error: unknown): unknown {
@ -68,6 +92,7 @@ export function tagPreAcceptSendFailure(error: unknown): unknown {
const passthroughCodes = new Set([
"PREMIUM_QUOTA_EXHAUSTED",
"THREAD_BUSY",
"TURN_CANCELLING",
"AUTH_EXPIRED",
"UNAUTHORIZED",
"RATE_LIMITED",

View file

@ -21,6 +21,7 @@ export type SharedStreamEventContext = {
scheduleFlush: () => void;
forceFlush: () => void;
onTokenUsage?: (data: Extract<SSEEvent, { type: "data-token-usage" }>["data"]) => void;
onTurnStatus?: (data: Extract<SSEEvent, { type: "data-turn-status" }>["data"]) => void;
onToolOutputAvailable?: (
event: Extract<SSEEvent, { type: "tool-output-available" }>,
context: {
@ -173,6 +174,10 @@ export function processSharedStreamEvent(parsed: SSEEvent, context: SharedStream
context.onTokenUsage?.(parsed.data);
return true;
case "data-turn-status":
context.onTurnStatus?.(parsed.data);
return true;
case "error":
throw toStreamTerminalError(parsed);

View file

@ -528,6 +528,14 @@ export type SSEEvent =
}>;
};
}
| {
type: "data-turn-status";
data: {
status: "idle" | "busy" | "cancelling";
retry_after_ms?: number;
retry_after_at?: number;
};
}
| {
type: "data-token-usage";
data: {