From 5be61dbcd623cdc008f2da5bf0111edd82809a77 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 26 Jul 2026 16:30:52 -0300 Subject: [PATCH] feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path (#8622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): selective upstream 4xx error passthrough util * feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path createErrorResult() gains an opt-in 7th param opts.passthrough; when set and the upstream body is eligible (per shouldPassthroughUpstreamError), the returned response body is the upstream 4xx body verbatim instead of the sanitized wrapper. Internal classification fields (error/rawMessage/ errorType/errorCode) are never affected. Wired into chatCore.ts's 7 upstream-error createErrorResult call sites (model_unavailable / context_overflow / generic upstream-error branches), gated on sourceFormat === FORMATS.CLAUDE so only /v1/messages requests get the verbatim body — OpenAI-format paths are unchanged. * test(quality): register upstream-error-passthrough in stryker tap.testFiles `tests/unit/upstream-error-passthrough.test.ts` covers `open-sse/utils/error.ts`, an instrumented module, so `check:mutation-test-coverage --strict` requires it in `tap.testFiles` — the gate flagged the drift on this PR. --- open-sse/handlers/chatCore.ts | 21 ++-- open-sse/utils/error.ts | 20 +++- open-sse/utils/upstreamErrorPassthrough.ts | 38 ++++++ stryker.conf.json | 1 + tests/unit/upstream-error-passthrough.test.ts | 112 ++++++++++++++++++ 5 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 open-sse/utils/upstreamErrorPassthrough.ts create mode 100644 tests/unit/upstream-error-passthrough.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index af5c1ddfe2..5c4f52b391 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3769,7 +3769,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } catch { @@ -3788,7 +3789,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } else { @@ -3807,7 +3809,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } else if (isContextOverflowError(statusCode, message)) { @@ -3855,7 +3858,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } catch { @@ -3874,7 +3878,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } else { @@ -3893,7 +3898,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } } else { @@ -3919,7 +3925,8 @@ export async function handleChatCore({ retryAfterMs, upstreamErrorCode, upstreamErrorType, - upstreamErrorBody + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } ); } // ── End T5 ─────────────────────────────────────────────────────────────── diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index e78e7605c9..c624e85886 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -3,6 +3,7 @@ import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; +import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts"; /** * Sanitize an error message to prevent stack trace exposure in API responses. @@ -529,7 +530,8 @@ export function createErrorResult( retryAfterMs: number | null = null, errorCode?: string, errorType?: string, - upstreamDetails?: unknown + upstreamDetails?: unknown, + opts?: { passthrough?: boolean } ) { const body = buildErrorBody(statusCode, message, upstreamDetails); if (errorCode) { @@ -577,6 +579,22 @@ export function createErrorResult( result.retryAfterMs = retryAfterMs; } + // Opt-in relay of the verbatim upstream error body (Claude Code auto-recover + // contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`; + // `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so + // server-side classification (checkFallbackError, combo retry logic, etc.) + // never sees a different value depending on this flag. + if (opts?.passthrough) { + const passthroughResponse = buildPassthroughErrorResponse( + statusCode, + upstreamDetails, + retryAfterMs ? { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) } : undefined + ); + if (passthroughResponse) { + result.response = passthroughResponse; + } + } + return result; } diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts new file mode 100644 index 0000000000..651aa2f77a --- /dev/null +++ b/open-sse/utils/upstreamErrorPassthrough.ts @@ -0,0 +1,38 @@ +/** + * Selective upstream 4xx error passthrough (Claude Code auto-recover contract). + * + * Claude Code matches the upstream error WORDING to auto-disable capabilities + * (thinking / output_config) for the rest of the conversation. Wrapping the body + * via buildErrorBody() truncates the message and breaks that recovery. For + * upstream-originated 4xx errors the body is the provider's public API message — + * not our internals — so it is safe and required to relay it verbatim. + * OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12). + */ +const PASSTHROUGH_MIN = 400; +const PASSTHROUGH_MAX = 499; +// 401/403/407: auth-adjacent — our own credential context may leak via provider +// echoes; keep those sanitized. 400/404/408/413/422/429 carry the capability and +// quota wording the client needs. +const EXCLUDED_STATUSES = new Set([401, 403, 407]); +const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i; + +export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean { + if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false; + if (EXCLUDED_STATUSES.has(statusCode)) return false; + if (!upstreamBody || typeof upstreamBody !== "object") return false; + const text = JSON.stringify(upstreamBody); + if (INTERNAL_LEAK_RE.test(text)) return false; + return true; +} + +export function buildPassthroughErrorResponse( + statusCode: number, + upstreamBody: unknown, + headers?: Record +): Response | null { + if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null; + return new Response(JSON.stringify(upstreamBody), { + status: statusCode, + headers: { "Content-Type": "application/json", ...(headers || {}) }, + }); +} diff --git a/stryker.conf.json b/stryker.conf.json index 7fca679c21..f648a8647e 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -294,6 +294,7 @@ "tests/unit/tproxy-route.test.ts", "tests/unit/trae-publiccred.test.ts", "tests/unit/types-barrel-model-cooldown.test.ts", + "tests/unit/upstream-error-passthrough.test.ts", "tests/unit/upstream-retry-hints-toggle.test.ts", "tests/unit/upstream-timeout-model-override.test.ts", "tests/unit/usage-service-hardening.test.ts", diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts new file mode 100644 index 0000000000..0152775ff4 --- /dev/null +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + shouldPassthroughUpstreamError, + buildPassthroughErrorResponse, +} from "../../open-sse/utils/upstreamErrorPassthrough.ts"; + +test("upstream error passthrough", async (t) => { + await t.test("4xx com corpo JSON de erro do provider é elegível", () => { + const body = { + type: "error", + error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" }, + }; + assert.equal(shouldPassthroughUpstreamError(400, body), true); + }); + await t.test("5xx NÃO é elegível (segue sanitizado)", () => { + assert.equal(shouldPassthroughUpstreamError(500, { error: { message: "x" } }), false); + }); + await t.test("corpo com cara de vazamento interno (stack trace) NÃO é elegível", () => { + assert.equal( + shouldPassthroughUpstreamError(400, { + error: { message: "Error\n at /usr/lib/node_modules/omniroute/x.js:1" }, + }), + false + ); + }); + await t.test( + "401/407 NÃO são elegíveis (credencial nossa pode vazar em www-authenticate)", + () => { + assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false); + } + ); + await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => { + const body = { + type: "error", + error: { type: "invalid_request_error", message: "thinking.type: nope" }, + }; + const res = buildPassthroughErrorResponse(400, body); + assert.ok(res); + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), body); + }); + await t.test("retorna null quando inelegível", () => { + assert.equal(buildPassthroughErrorResponse(500, {}), null); + }); +}); + +test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => { + await t.test( + "com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim", + async () => { + const { createErrorResult } = await import("../../open-sse/utils/error.ts"); + const upstreamBody = { + type: "error", + error: { + type: "invalid_request_error", + message: "thinking.type: adaptive is not supported", + }, + }; + const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody, { + passthrough: true, + }); + assert.deepEqual(await result.response.json(), upstreamBody); + assert.equal(result.status, 400); + // Internal classification fields must never be affected by passthrough. + assert.equal(typeof result.error, "string"); + assert.notEqual(result.error, JSON.stringify(upstreamBody)); + } + ); + + await t.test("sem opts, comportamento atual (corpo sanitizado) é preservado", async () => { + const { createErrorResult } = await import("../../open-sse/utils/error.ts"); + const upstreamBody = { + type: "error", + error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" }, + }; + const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody); + const body = (await result.response.json()) as { error?: { message?: string } }; + assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape"); + assert.ok( + !JSON.stringify(body).includes(" at /"), + "sanitized body never leaks stack-trace-like text" + ); + }); + + await t.test("com retryAfterMs e passthrough elegível, header Retry-After é setado", async () => { + const { createErrorResult } = await import("../../open-sse/utils/error.ts"); + const upstreamBody = { + type: "error", + error: { type: "rate_limit_error", message: "slow down" }, + }; + const result = createErrorResult(429, "msg", 5000, "code", "type", upstreamBody, { + passthrough: true, + }); + assert.equal(result.response.headers.get("Retry-After"), "5"); + assert.deepEqual(await result.response.json(), upstreamBody); + }); + + await t.test( + "opts.passthrough true mas corpo inelegível (401) cai no corpo sanitizado atual", + async () => { + const { createErrorResult } = await import("../../open-sse/utils/error.ts"); + const upstreamBody = { error: { message: "bad key" } }; + const result = createErrorResult(401, "unauthorized", null, "code", "type", upstreamBody, { + passthrough: true, + }); + const body = (await result.response.json()) as { error?: { message?: string } }; + assert.notDeepEqual(body, upstreamBody); + assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape"); + } + ); +});