From 6c8674e772d455808a90af6ddd609412d3bd7f4b Mon Sep 17 00:00:00 2001 From: gitrvc-hub Date: Tue, 3 Nov 2026 04:27:34 -0500 Subject: [PATCH 1/2] fix(claude): close the uncoded overflow gaps left after #8128 #8128 answers an uncoded context-window refusal as prompt too long on the translated stream, the non-stream error branch and the failed-JSON branch. Four shapes still reached Claude Code as a retry or a 502: - The shared transient retry resent a 5xx overflow twice before the Claude path classified it. It now returns a 5xx whose body is a definite overflow verdict without resending. The body is peeked from a clone within a byte and time bound, so the caller keeps the untouched body and a slow or endless body still gets the normal retry; a 5xx throttle worded only as "too many tokens" is still retried. - upstream_server_error, the code the shared classifier gives every 5xx, now counts as a generic failure code like server_error. - The direct Messages encoder attaches context_length_exceeded with the same isContextOverflowError verdict as the translated stream. - A plain-text (non-JSON) overflow body now answers 400 with the upstream text as its message. Co-Authored-By: Claude --- src/claude/outbound.ts | 3 +- src/lib/upstream-retry.ts | 46 +++++++++++++++++++ src/protocols/encoders/messages.ts | 4 +- src/server/claude-messages.ts | 6 ++- .../claude-uncoded-context-overflow.test.ts | 21 ++++++++- tests/lib/upstream-retry.test.ts | 34 ++++++++++++++ .../protocol-direct-encoders-messages.test.ts | 14 ++++++ 7 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index e886da667ec..8b462aa3139 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -133,7 +133,8 @@ export function isThroughputLimitText(message: string): boolean { } /** Codes that say nothing about the cause, so the message decides (#8127). */ -const GENERIC_FAILURE_CODES: ReadonlySet = new Set(["server_error", "upstream_error", "api_error", "internal_error"]); +/** `upstream_server_error` is what the shared classifier gives every 5xx, so it is generic too. */ +const GENERIC_FAILURE_CODES: ReadonlySet = new Set(["server_error", "upstream_error", "upstream_server_error", "api_error", "internal_error"]); const NON_OVERFLOW_ERROR_TYPES: ReadonlySet = new Set(["rate_limit_error", "authentication_error", "permission_error"]); const NON_OVERFLOW_STATUSES: ReadonlySet = new Set([401, 403, 429]); /** diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 2e366828e5f..05d21ea5f51 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -16,6 +16,7 @@ * the shared abort helpers from here). */ import { clearableDeadline } from "./abort"; +import { isDefiniteContextOverflow } from "./context-overflow-verdict"; import { errorDestinationLabel, errorDestinationText, type ErrorDestinationLabel } from "./error-destination"; export { errorDestinationLabel, providerErrorDestinationLabel, errorDestinationText } from "./error-destination"; import { redactSecretString } from "./redact"; @@ -721,6 +722,48 @@ export async function fetchWithResetRetry( } finally { opts.onSendsConsumed?.close?.(); } } +/** Bytes of a 5xx body read to look for a context-overflow verdict; the verdict caps its input too. */ +const CONTEXT_OVERFLOW_PEEK_BYTES = 16_384; +/** A body that has not arrived by then is not evidence; the peek must never stall the retry. */ +const CONTEXT_OVERFLOW_PEEK_MS = 250; + +/** Up to `maxBytes` of a clone of the body, or what arrived before the deadline. */ +async function peekBodyText(res: Response, maxBytes: number, deadlineMs: number): Promise { + const reader = res.clone().body?.getReader(); + if (!reader) return ""; + const decoder = new TextDecoder(); + let text = ""; + let timer: ReturnType | undefined; + const timedOut = new Promise(resolve => { timer = setTimeout(() => resolve(null), deadlineMs); }); + try { + while (text.length < maxBytes) { + const chunk = await Promise.race([reader.read(), timedOut]); + if (!chunk || chunk.done) break; + text += decoder.decode(chunk.value, { stream: true }); + } + return text.slice(0, maxBytes); + } finally { + clearTimeout(timer); + reader.cancel().catch(() => {}); + } +} + +/** + * True when a transient-status response's body is a definite context-overflow verdict. Peeks a + * clone within a byte and time bound, so the caller still receives the untouched body and a slow + * or endless body keeps the normal retry. "Too many tokens" alone is not enough here: a 5xx + * "Too many tokens, please wait" is a throttle that the retry exists for. + */ +async function isContextOverflowResponse(res: Response): Promise { + if ((res.headers.get("content-type") ?? "").includes("text/event-stream")) return false; + try { + const text = await peekBodyText(res, CONTEXT_OVERFLOW_PEEK_BYTES, CONTEXT_OVERFLOW_PEEK_MS); + return text.length > 0 && isDefiniteContextOverflow(res.status, text.replace(/too many tokens/gi, "")); + } catch { + return false; + } +} + /** * fetchWithResetRetry plus the caller-selected transient-5xx policy, PRE-STREAM only. * A received HTTP error follows that policy; an ambiguous reset's non-replayable @@ -782,6 +825,9 @@ export async function fetchWithTransientRetry( // thing about its own failure, so the ladder stops and hands the response back untouched. if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res) || res.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER) === REPLAY_REFUSAL_NO_RETRY_VALUE) return res; + // A 5xx that says the input does not fit is deterministic: resending the same body is + // refused the same way, so it goes back for the caller to classify (#8127). + if (await isContextOverflowResponse(res)) return res; // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives // a response whose body we just cancelled. if (opts.abortSignal?.aborted) return res; diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index d41d8004306..b000348d9e8 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -28,6 +28,7 @@ import { boundedReasoningIdentity, collectAnthropicMessage, isClaudeWebSearchToolName, + isContextOverflowError, messageSnapshot, sanitizeWebSearchInput, sseFrame, @@ -389,8 +390,9 @@ function createAnthropicMessageWriter( } const message = typeof error.message === "string" ? error.message : "upstream request failed"; const deterministic = error.code === UNDECLARED_TOOL_CALL_ERROR_CODE; + // Same overflow verdict as the translated stream, so an uncoded refusal compacts here too (#8127). fail(anthropicFailedStatus(error, message), message, !deterministic, - deterministic || error.code === "context_length_exceeded" ? error.code as string : undefined); + deterministic ? error.code as string : isContextOverflowError(error, message) ? "context_length_exceeded" : undefined); }, overflow() { fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1a7192e91f4..fa09a01a898 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1301,7 +1301,11 @@ async function handleClaudeMessagesWithBudget( || (![401, 403, 429].includes(response.status) && Boolean(nested || flat) && isContextOverflowError(errorObject, message)); } catch { - if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`; + // A plain-text gateway body carries the same verdict with no envelope to read a code from. + const plain = text.trim().slice(0, 400); + contextError = Boolean(plain) && ![401, 403, 429].includes(response.status) + && isContextOverflowError({}, plain); + if (text) message = contextError ? plain : `upstream error (${response.status}): ${text.slice(0, 400)}`; } } catch { /* keep fallback message */ } const upstreamRetryAfter = response.headers.get("retry-after"); diff --git a/tests/claude-integration/claude-uncoded-context-overflow.test.ts b/tests/claude-integration/claude-uncoded-context-overflow.test.ts index 0fe5a35843f..6e784a42358 100644 --- a/tests/claude-integration/claude-uncoded-context-overflow.test.ts +++ b/tests/claude-integration/claude-uncoded-context-overflow.test.ts @@ -88,7 +88,12 @@ async function translatedErrorFrame(error: Record) { } describe("translated Responses stream", () => { - test.each([{ message: OVERFLOW }, { type: "server_error", code: "server_error", message: OVERFLOW }])( + test.each([ + { message: OVERFLOW }, + { type: "server_error", code: "server_error", message: OVERFLOW }, + // The shared classifier's code for every 5xx is generic, not a specific cause. + { type: "server_error", code: "upstream_server_error", message: OVERFLOW }, + ])( "an uncoded or server_error overflow ends in a prompt-too-long 400 frame: %j", async error => { const frame = await translatedErrorFrame(error); expect(frame).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded" }); @@ -141,6 +146,20 @@ describe("translated Messages route end to end", () => { expect(response.headers.get("retry-after")).toBeNull(); }); + test("a plain-text 500 overflow answers 400 with the upstream text", async () => { + const { response, text } = await send(() => new Response(OVERFLOW, { status: 500 })); + expect(response.status).toBe(400); + const error = JSON.parse(text).error; + expect(error).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded" }); + expect(error.message).toContain(OVERFLOW); + expect(error.message).not.toContain("upstream error (500)"); + }); + + test("a plain-text 500 that is not an overflow keeps its retryable class", async () => { + const { response } = await send(() => new Response("bad gateway", { status: 500 })); + expect(response.status).toBe(529); + }); + test("a 429 that mentions the context window stays a rate limit", async () => { const { response, text } = await send(() => Response.json({ error: { type: "rate_limit_error", message: "context window quota exhausted" } }, { status: 429 })); expect(response.status).toBe(429); diff --git a/tests/lib/upstream-retry.test.ts b/tests/lib/upstream-retry.test.ts index 6fae42ccfef..aad04bac6f8 100644 --- a/tests/lib/upstream-retry.test.ts +++ b/tests/lib/upstream-retry.test.ts @@ -321,6 +321,40 @@ describe("retryBackoffDelayMs", () => { expect(mock.calls).toHaveLength(1); }); + test("a 5xx context-overflow verdict is returned without a resend (#8127)", async () => { + silenceWarn(); + for (const body of [ + JSON.stringify({ error: { type: "server_error", message: "Your input exceeds the context window of this model" } }), + "Your input exceeds the context window of this model", + ]) { + const upstream = new Response(body, { status: 500 }); + const mock = mockDoFetch([upstream, new Response("duplicate"), new Response("duplicate")]); + const response = await fetchWithTransientRetry(mock.doFetch); + expect(response).toBe(upstream); + expect(await response.text()).toBe(body); + expect(mock.calls).toHaveLength(1); + } + }); + + test("a 5xx body that never ends does not stall the overflow check (#8127)", async () => { + silenceWarn(); + const mock = mockDoFetch([new Response(new ReadableStream(), { status: 503 }), new Response("ok")]); + const response = await fetchWithTransientRetry(mock.doFetch); + expect(response.status).toBe(200); + expect(mock.calls).toHaveLength(2); + }); + + test("a 5xx throttle worded as too many tokens is still retried (#8127)", async () => { + silenceWarn(); + const mock = mockDoFetch([ + new Response(JSON.stringify({ error: { message: "Too many tokens, please wait before trying again." } }), { status: 500 }), + new Response("ok"), + ]); + const response = await fetchWithTransientRetry(mock.doFetch); + expect(response.status).toBe(200); + expect(mock.calls).toHaveLength(2); + }); + test("an instruction inside the wait deadline is still honoured before retrying (#4546)", async () => { silenceWarn(); const limited = new Response("overloaded", { diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts index 666e8f4f7cc..7d837124684 100644 --- a/tests/responses/protocol-direct-encoders-messages.test.ts +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -354,6 +354,20 @@ describe("direct Messages fold matches bridge + collector (non-stream)", () => { } }); }); + // #8127: the direct encoder gives an uncoded overflow the same code as the translated stream. + test.each([[undefined, undefined], ["server_error", "upstream_server_error"]] as const)( + "uncoded overflow (type %s, code %s) becomes a prompt-too-long 400", + async (errorType, code) => { + const message = "Your input exceeds the context window of this model"; + const events: AdapterEvent[] = [{ type: "error", errorType, code, message, retryable: false }]; + const error = { type: "invalid_request_error", code: "context_length_exceeded", message: `prompt is too long: ${message}` }; + expect(await expectStreamParity(events)).toEqual([{ event: "error", data: { type: "error", error } }]); + const response = await foldAnthropicMessage(replay(events), directOptions()); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ type: "error", error }); + }, + ); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectFoldParity(scenario.events, scenario.options); From 9424d4bd6cee25cc851f0002ab759d37ba961a28 Mon Sep 17 00:00:00 2001 From: gitrvc-hub Date: Tue, 3 Nov 2026 04:42:30 -0500 Subject: [PATCH 2/2] fix(claude): keep 402 out of the plain-text overflow verdict and document the gaps - A plain-text 402 that mentions the context window keeps its billing status instead of becoming a prompt-too-long 400 (CodeRabbit review). - Update the owner docs for the changed areas (Codex review): structure/runtime.md for the translated-lane verdict additions, structure/data-planes/error-detail.md for the retry stop in src/lib/upstream-retry.ts, and structure/data-planes/protocol-paths.md for the shared helper the direct Messages encoder now uses. Co-Authored-By: Claude --- src/server/claude-messages.ts | 2 +- structure/data-planes/error-detail.md | 7 ++++++- structure/data-planes/protocol-paths.md | 2 +- structure/runtime.md | 2 +- .../claude-uncoded-context-overflow.test.ts | 5 +++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index fa09a01a898..1065e8378cc 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1303,7 +1303,7 @@ async function handleClaudeMessagesWithBudget( } catch { // A plain-text gateway body carries the same verdict with no envelope to read a code from. const plain = text.trim().slice(0, 400); - contextError = Boolean(plain) && ![401, 403, 429].includes(response.status) + contextError = Boolean(plain) && ![401, 402, 403, 429].includes(response.status) && isContextOverflowError({}, plain); if (text) message = contextError ? plain : `upstream error (${response.status}): ${text.slice(0, 400)}`; } diff --git a/structure/data-planes/error-detail.md b/structure/data-planes/error-detail.md index 58303fde43b..7376e61f2b2 100644 --- a/structure/data-planes/error-detail.md +++ b/structure/data-planes/error-detail.md @@ -208,7 +208,12 @@ rows to client or catalog model identities and validated provider ids, omitting is interpolated into those local messages. No serialized flag, code or header mints identity. The shared builders live in `src/combos/resolve.ts`, `src/server/admission-model-scope.ts`, `src/server/responses/input-admission.ts` and `src/oauth/generic-account-failover.ts`. -`src/lib/upstream-retry.ts` owns the retry constructors. +`src/lib/upstream-retry.ts` owns the retry constructors. `fetchWithTransientRetry` does not resend a +transient-status response whose body is a definite context-overflow verdict +(`isDefiniteContextOverflow`, #8127): the same input is refused the same way, so it is returned for the +caller to classify. The check reads a clone of at most 16 KiB within 250 ms, so the caller still receives +the untouched body, a slow, endless or event-stream body keeps the normal retry, and a throttle worded only +as "too many tokens" is still retried. `InvalidSendBudgetError` displays only a finite non-negative number or `invalid`. `UpstreamRetryEvidenceError` uses fixed retry-evidence wording and retains the original rejection only as `Error.cause`. Chat request-read failures keep typed local size/encoding messages; other diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 4b7ed7e8599..41b7683f8eb 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -225,7 +225,7 @@ the wire-silence heartbeat and live-destination stall watchdog ([timeout policy] converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/outbound.ts` (ids, chunk and frame builders, usage mapping, `chatCompletionsStreamErrorPayload`, `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, -`anthropicIncompleteOutcome`, `anthropicFailedStatus`, the message snapshot, the web-search pair), +`anthropicIncompleteOutcome`, `anthropicFailedStatus`, `isContextOverflowError`, the message snapshot, the web-search pair), so the frames a client receives are the converters' frames. A fold is the encoded stream read by the existing collector. Only producer-marked watchdog/EOF terminals retain local Chat wording; upstream copies of their reason or message follow [client error normalization](error-detail.md#client-error-bodies-chat). Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome. diff --git a/structure/runtime.md b/structure/runtime.md index ca2ae5820fc..f93650c4868 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence awaits the fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window), rechecks observed selection/epoch and caller-currentness changes after that await, and returns detached frozen results; signal cancellation makes the persistence wrapper return null without writing, while raw async resolver rejections and exceptions without signal cancellation retain their rejection behavior; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Known Codex.app and ChatGPT.app commands at any location or alias follow the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust); automatic discovery also requires matching bundle identity; the existing ChatGPT bundle trust policy (safe ownership, permissions, non-symlink paths, and OpenAI signatures for both the bundle and the exact executable) is enforced at every execution boundary in src/codex/exec-boundary.ts before the resolved canonical target is spawned, not once at selection. Version probes retain that execution's bundle classification for selection, so a bare PATH alias of an app cannot supersede a discovered persisted runtime; selection does not resolve PATH again after the probe. Stored and displayed command spelling remains unchanged, and this observation grants no trust to a later execution. Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). On the translated Messages-to-Responses lane, `isContextOverflowError` in `src/claude/outbound.ts` decides overflow for the streamed `response.failed` handler, the non-stream error branch and the failed-JSON branch: a coded `context_length_exceeded` always counts; an uncoded or generic-coded (`server_error`, `upstream_error`, `api_error`, `internal_error`) refusal counts only on wording that asserts the request exceeds a size limit, never with a 401/403/429 status, a rate-limit or credential type, throughput wording or another specific code, and then answers 400 `prompt is too long` so Claude Code compacts (#8127, `tests/claude-integration/claude-uncoded-context-overflow.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence awaits the fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window), rechecks observed selection/epoch and caller-currentness changes after that await, and returns detached frozen results; signal cancellation makes the persistence wrapper return null without writing, while raw async resolver rejections and exceptions without signal cancellation retain their rejection behavior; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Known Codex.app and ChatGPT.app commands at any location or alias follow the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust); automatic discovery also requires matching bundle identity; the existing ChatGPT bundle trust policy (safe ownership, permissions, non-symlink paths, and OpenAI signatures for both the bundle and the exact executable) is enforced at every execution boundary in src/codex/exec-boundary.ts before the resolved canonical target is spawned, not once at selection. Version probes retain that execution's bundle classification for selection, so a bare PATH alias of an app cannot supersede a discovered persisted runtime; selection does not resolve PATH again after the probe. Stored and displayed command spelling remains unchanged, and this observation grants no trust to a later execution. Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). On the translated Messages-to-Responses lane, `isContextOverflowError` in `src/claude/outbound.ts` decides overflow for the streamed `response.failed` handler, the non-stream error branch and the failed-JSON branch: a coded `context_length_exceeded` always counts; an uncoded or generic-coded (`server_error`, `upstream_error`, `api_error`, `internal_error`) refusal counts only on wording that asserts the request exceeds a size limit, never with a 401/403/429 status, a rate-limit or credential type, throughput wording or another specific code, and then answers 400 `prompt is too long` so Claude Code compacts (#8127, `tests/claude-integration/claude-uncoded-context-overflow.test.ts`) `upstream_server_error`, the code the shared classifier gives every 5xx, is generic there too. The non-stream branch applies the same verdict to a plain-text (non-JSON) body, excluding 401/402/403/429, and answers with the upstream text as the message; the direct Messages encoder attaches `context_length_exceeded` with the same verdict. Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. ## Bun runtime and test runner diff --git a/tests/claude-integration/claude-uncoded-context-overflow.test.ts b/tests/claude-integration/claude-uncoded-context-overflow.test.ts index 6e784a42358..bd916edd85a 100644 --- a/tests/claude-integration/claude-uncoded-context-overflow.test.ts +++ b/tests/claude-integration/claude-uncoded-context-overflow.test.ts @@ -155,6 +155,11 @@ describe("translated Messages route end to end", () => { expect(error.message).not.toContain("upstream error (500)"); }); + test.each([402, 429])("a plain-text HTTP %i that mentions the context window keeps its status", async status => { + const { response } = await send(() => new Response(OVERFLOW, { status })); + expect(response.status).toBe(status); + }); + test("a plain-text 500 that is not an overflow keeps its retryable class", async () => { const { response } = await send(() => new Response("bad gateway", { status: 500 })); expect(response.status).toBe(529);