Merge pull request #2752 from MoerAI/fix/quota-error-fallback-detection

fix(runtime-fallback): detect prettified quota errors without HTTP status codes (fixes #2747)
This commit is contained in:
YeonGyu-Kim
2026-03-26 08:50:58 +09:00
committed by GitHub
3 changed files with 129 additions and 1 deletions

View File

@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
*/
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
enabled: false,
retry_on_errors: [429, 500, 502, 503, 504],
retry_on_errors: [402, 429, 500, 502, 503, 504],
max_fallback_attempts: 3,
cooldown_seconds: 60,
timeout_seconds: 30,
@@ -37,6 +37,11 @@ export const RETRYABLE_ERROR_PATTERNS = [
/try.?again/i,
/credit.*balance.*too.*low/i,
/insufficient.?(?:credits?|funds?|balance)/i,
/subscription.*quota/i,
/billing.?(?:hard.?)?limit/i,
/payment.?required/i,
/out\s+of\s+credits?/i,
/(?:^|\s)402(?:\s|$)/,
/(?:^|\s)429(?:\s|$)/,
/(?:^|\s)503(?:\s|$)/,
/(?:^|\s)529(?:\s|$)/,

View File

@@ -166,3 +166,100 @@ describe("extractStatusCode", () => {
expect(extractStatusCode(error)).toBe(400)
})
})
describe("quota error detection (fixes #2747)", () => {
test("classifies prettified subscription quota error as quota_exceeded", () => {
//#given
const error = {
name: "AI_APICallError",
message: "Subscription quota exceeded. You can continue using free models.",
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
test("classifies billing hard limit error as quota_exceeded", () => {
//#given
const error = { message: "You have reached your billing hard limit." }
//#when
const errorType = classifyErrorType(error)
//#then
expect(errorType).toBe("quota_exceeded")
})
test("classifies exhausted capacity error as quota_exceeded", () => {
//#given
const error = { message: "You have exhausted your capacity on this model." }
//#when
const errorType = classifyErrorType(error)
//#then
expect(errorType).toBe("quota_exceeded")
})
test("classifies out of credits error as quota_exceeded", () => {
//#given
const error = { message: "Out of credits. Please add more credits to continue." }
//#when
const errorType = classifyErrorType(error)
//#then
expect(errorType).toBe("quota_exceeded")
})
test("treats HTTP 402 Payment Required as retryable", () => {
//#given
const error = { statusCode: 402, message: "Payment Required" }
//#when
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
//#then
expect(retryable).toBe(true)
})
test("matches subscription quota pattern in RETRYABLE_ERROR_PATTERNS", () => {
//#given
const error = { message: "Subscription quota exceeded. You can continue using free models." }
//#when
const retryable = isRetryableError(error, [429, 503])
//#then
expect(retryable).toBe(true)
})
test("classifies QuotaExceededError by errorName even without quota keywords in message", () => {
//#given
const error = { name: "QuotaExceededError", message: "Request failed." }
//#when
const errorType = classifyErrorType(error)
//#then
expect(errorType).toBe("quota_exceeded")
})
test("detects payment required errors as retryable", () => {
//#given
const error = { message: "Error 402: payment required for this request" }
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 503])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
})

View File

@@ -21,6 +21,13 @@ export function getErrorMessage(error: unknown): string {
}
}
const errorObj2 = error as Record<string, unknown>
const name = errorObj2.name
if (typeof name === "string" && name.length > 0) {
const nameColonMatch = name.match(/:\s*(.+)/)
if (nameColonMatch) return nameColonMatch[1].trim().toLowerCase()
}
try {
return JSON.stringify(error).toLowerCase()
} catch {
@@ -112,6 +119,21 @@ export function classifyErrorType(error: unknown): string | undefined {
return "model_not_found"
}
if (
errorName?.includes("quotaexceeded") ||
errorName?.includes("insufficientquota") ||
errorName?.includes("billingerror") ||
/quota.?exceeded/i.test(message) ||
/subscription.*quota/i.test(message) ||
/insufficient.?quota/i.test(message) ||
/billing.?(?:hard.?)?limit/i.test(message) ||
/exhausted\s+your\s+capacity/i.test(message) ||
/out\s+of\s+credits?/i.test(message) ||
/payment.?required/i.test(message)
) {
return "quota_exceeded"
}
return undefined
}
@@ -181,6 +203,10 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
return true
}
if (errorType === "quota_exceeded") {
return true
}
if (statusCode && retryOnErrors.includes(statusCode)) {
return true
}