diff --git a/changelog.d/fixes/7907-chatcore-499-string-abort.md b/changelog.d/fixes/7907-chatcore-499-string-abort.md new file mode 100644 index 0000000000..0d3ef1e90c --- /dev/null +++ b/changelog.d/fixes/7907-chatcore-499-string-abort.md @@ -0,0 +1 @@ +- **Chat Core**: client aborts that reject the upstream fetch with a raw string reason (e.g. `request_signal_aborted`, `Client disconnected`) are now reported as `499 Request aborted` in the response, request logs, and usage records instead of `FAILED 502 / Bad Gateway`. Follow-up to #7908 for #7907. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 168c06c343..6375a310cb 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -319,6 +319,7 @@ import { stripMarkdownCodeFence, } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; +import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker"; import { extractFacts } from "@/lib/memory/extraction"; import { handleToolCallExecution } from "@/lib/skills/interception"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; @@ -3112,18 +3113,21 @@ export async function handleChatCore({ errorCode: error.code, }; } - const failureStatus = - error.name === "AbortError" - ? 499 - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" - ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status - : HTTP_STATUS.BAD_GATEWAY; - const failureMessage = - error.name === "AbortError" - ? "Request aborted" - : formatProviderError(error, provider, model, failureStatus); + // abort(reason) can reject the upstream fetch with a raw string reason + // (e.g. "request_signal_aborted") that has no `name`/`status`; classify + // via isLocalStreamLifecycleError so those map to 499 instead of falling + // through to the 502 provider-failure default. + const isRequestAborted = isLocalStreamLifecycleError(error); + const failureStatus = isRequestAborted + ? 499 + : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; + const failureMessage = isRequestAborted + ? "Request aborted" + : formatProviderError(error, provider, model, failureStatus); const upstreamErrorCode = getUpstreamErrorIdentifier(error); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a @@ -3152,7 +3156,7 @@ export async function handleChatCore({ claudeCacheMeta: claudePromptCacheLogMeta, cacheSource: "upstream", }); - if (error.name === "AbortError") { + if (isRequestAborted) { streamController.handleError(error); return createErrorResult(499, "Request aborted"); } diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index eb12a8c8f0..ed4310024e 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -2367,6 +2367,28 @@ test("chatCore maps upstream aborts to request-aborted errors", async () => { assert.equal(result.error, "Request aborted"); }); +test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () => { + // abort(reason) rejects the upstream fetch with the raw reason — often a + // bare string with no `name`/`status`. It must map to 499 like a named + // AbortError, not fall through to the 502 provider-failure default. + const { result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "abort me with a string reason" }], + }, + responseFactory() { + throw "request_signal_aborted"; + }, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 499); + assert.equal(result.error, "Request aborted"); +}); + test("chatCore returns streaming responses without waiting for upstream completion", async () => { const encoder = new TextEncoder(); let closeUpstream: (() => void) | null = null;