From 30b226c44e081638f79ee09ba9a0212698a587e6 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Thu, 13 Aug 2026 16:24:28 -0300 Subject: [PATCH] feat(sse): restate agentrouter quota 403/400 to 429 before classification --- open-sse/handlers/chatCore.ts | 22 +++++++++++++++++++ .../unit/upstream-status-restatement.test.ts | 17 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d762d8bd2d..eb0d5b587b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -184,6 +184,7 @@ import { DEFAULT_MAX_TOKENS, STREAM_DISCONNECT_GRACE_PERIOD_MS, } from "../config/constants.ts"; +import { applyStatusRestatement } from "../config/upstreamStatusRestatement.ts"; import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts"; import { resolveResilienceSettings, @@ -3650,6 +3651,27 @@ export async function handleChatCore({ upstreamErrorType = details.errorType as string | undefined; } + // Gateways like agentrouter misstate temporary quota exhaustion as 403/400, + // which downstream classification treats as AUTH_ERROR and clients like + // Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After) + // BEFORE any classification so both the fallback engine and the surfaced + // client status see a retryable error. Registry-scoped per provider. + const restatement = applyStatusRestatement({ + provider, + status: statusCode, + message, + body: upstreamErrorBody, + retryAfterMs, + }); + if (restatement.ruleId) { + statusCode = restatement.status; + retryAfterMs = restatement.retryAfterMs; + log?.info?.( + "STATUS_RESTATE", + `${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})` + ); + } + const signatureRecovery = await recoverAnthropicThinkingSignature({ provider, statusCode, diff --git a/tests/unit/upstream-status-restatement.test.ts b/tests/unit/upstream-status-restatement.test.ts index 8d1c6c829b..7c59bc8b15 100644 --- a/tests/unit/upstream-status-restatement.test.ts +++ b/tests/unit/upstream-status-restatement.test.ts @@ -107,3 +107,20 @@ test("R9: registry exposes agentrouter so future gateways copy the one-line reci const rules = statusRestatementRegistry.get("agentrouter"); assert.ok(rules && rules.length > 0); }); + +test("R10: chatCore wires applyStatusRestatement into the providerFailure block", async () => { + // chatCore is a god-file that cannot be imported standalone in unit tests + // (side-effectful DB/env wiring), so the wiring contract is asserted at the + // source level: the hook must exist, run against the parsed error, and + // reassign both statusCode and retryAfterMs BEFORE classification. + const { readFile } = await import("node:fs/promises"); + const src = await readFile( + new URL("../../open-sse/handlers/chatCore.ts", import.meta.url), + "utf8" + ); + assert.match(src, /applyStatusRestatement\(/, "chatCore must call applyStatusRestatement"); + const hookIndex = src.indexOf("applyStatusRestatement("); + const classifyIndex = src.indexOf("classifyProviderError(statusCode"); + assert.ok(hookIndex > -1 && classifyIndex > -1 && hookIndex < classifyIndex, + "restatement must run BEFORE classifyProviderError so fallback sees the corrected status"); +});