diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index 3f011b333..3bd4409a4 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config" */ export const DEFAULT_CONFIG: Required = { 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|$)/, diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts index 954e28758..63e38733e 100644 --- a/src/hooks/runtime-fallback/error-classifier.test.ts +++ b/src/hooks/runtime-fallback/error-classifier.test.ts @@ -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) + }) +}) diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index e581f3fb8..088735edd 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -21,6 +21,13 @@ export function getErrorMessage(error: unknown): string { } } + const errorObj2 = error as Record + 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 }