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.
This commit is contained in:
YeonGyu-Kim
2026-05-25 01:32:23 +09:00
parent 222adeb606
commit f05e0cbe99
2 changed files with 93 additions and 3 deletions
@@ -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 = {
+16 -3
View File
@@ -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))