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:
@@ -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 = {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user