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
+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))