From f05e0cbe99bc08d575d8fe00fd96ca6e7f540bfb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 01:32:23 +0900 Subject: [PATCH] fix(runtime-fallback): gate retryable signal on status-code allowlist extractRetryableSignal returns the raw isRetryable hint from up to 5 nested AI SDK error paths. isRetryableError previously trusted any true result blindly, which would burn every configured fallback model in an infinite loop if a provider mis-tagged a 401, 403, or other non-transient 4xx as retryable. Honor the signal only when the status code is absent, in the configured retry_on_errors list, in 5xx, or in {408, 425, 429}. Reject the signal when the status code is a non-transient 4xx and log the rejection so operators can debug provider mis-classifications. Closes pre-publish blocker V8. --- .../runtime-fallback/error-classifier.test.ts | 77 +++++++++++++++++++ .../runtime-fallback/error-classifier.ts | 19 ++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts index fb311990e..fca01b901 100644 --- a/src/hooks/runtime-fallback/error-classifier.test.ts +++ b/src/hooks/runtime-fallback/error-classifier.test.ts @@ -111,6 +111,83 @@ describe("runtime-fallback error classifier", () => { expect(retryable).toBe(true) }) + test("isRetryableError REJECTS isRetryable=true when status code is 401 Unauthorized", () => { + //#given + const error = { error: { statusCode: 401, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(false) + }) + + test("isRetryableError REJECTS isRetryable=true when status code is 403 Forbidden", () => { + //#given + const error = { error: { statusCode: 403, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(false) + }) + + test("isRetryableError REJECTS isRetryable=true when status code is 404 Not Found", () => { + //#given + const error = { error: { statusCode: 404, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(false) + }) + + test("isRetryableError HONORS isRetryable=true when status code is 429 (rate-limit)", () => { + //#given + const error = { error: { statusCode: 429, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(true) + }) + + test("isRetryableError HONORS isRetryable=true when status code is 503 (service unavailable)", () => { + //#given + const error = { error: { statusCode: 503, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(true) + }) + + test("isRetryableError HONORS isRetryable=true when no status code is present (pure network error)", () => { + //#given + const error = { error: { isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(true) + }) + + test("isRetryableError HONORS isRetryable=true when status code is in retryOnErrors list", () => { + //#given + const error = { error: { statusCode: 400, isRetryable: true } } + + //#when + const retryable = isRetryableError(error, [400, 429, 503, 529]) + + //#then + expect(retryable).toBe(true) + }) + test("ignores malformed retryable flags on otherwise non-retryable errors", () => { //#given const error = { diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 76c970e81..4595f020f 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -1,4 +1,5 @@ -import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants" +import { DEFAULT_CONFIG, HOOK_NAME, RETRYABLE_ERROR_PATTERNS } from "./constants" +import { log } from "../../shared/logger" export { extractAutoRetrySignal } from "./auto-retry-signal" @@ -119,6 +120,10 @@ export function extractRetryableSignal(error: unknown): boolean | undefined { return undefined } +function isStatusCodeRetrySafe(code: number, retryOnErrors: number[]): boolean { + return retryOnErrors.includes(code) || (code >= 500 && code < 600) || code === 408 || code === 425 || code === 429 +} + function isLocalizedQuotaExhaustionMessage(message: string): boolean { return ( (/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) || @@ -221,8 +226,16 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole return true } - if (extractRetryableSignal(error) === true) { - return true + const retryableSignal = extractRetryableSignal(error) + if (retryableSignal === true) { + if (statusCode === undefined || isStatusCodeRetrySafe(statusCode, retryOnErrors)) { + return true + } + + log(`[${HOOK_NAME}] Retryable signal rejected due to unsafe status code`, { + statusCode, + retryOnErrors, + }) } return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))