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:
@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
retry_on_errors: [429, 500, 502, 503, 504],
|
retry_on_errors: [402, 429, 500, 502, 503, 504],
|
||||||
max_fallback_attempts: 3,
|
max_fallback_attempts: 3,
|
||||||
cooldown_seconds: 60,
|
cooldown_seconds: 60,
|
||||||
timeout_seconds: 30,
|
timeout_seconds: 30,
|
||||||
@@ -37,6 +37,11 @@ export const RETRYABLE_ERROR_PATTERNS = [
|
|||||||
/try.?again/i,
|
/try.?again/i,
|
||||||
/credit.*balance.*too.*low/i,
|
/credit.*balance.*too.*low/i,
|
||||||
/insufficient.?(?:credits?|funds?|balance)/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)429(?:\s|$)/,
|
||||||
/(?:^|\s)503(?:\s|$)/,
|
/(?:^|\s)503(?:\s|$)/,
|
||||||
/(?:^|\s)529(?:\s|$)/,
|
/(?:^|\s)529(?:\s|$)/,
|
||||||
|
|||||||
@@ -166,3 +166,100 @@ describe("extractStatusCode", () => {
|
|||||||
expect(extractStatusCode(error)).toBe(400)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
return JSON.stringify(error).toLowerCase()
|
return JSON.stringify(error).toLowerCase()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -112,6 +119,21 @@ export function classifyErrorType(error: unknown): string | undefined {
|
|||||||
return "model_not_found"
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +203,10 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorType === "quota_exceeded") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (statusCode && retryOnErrors.includes(statusCode)) {
|
if (statusCode && retryOnErrors.includes(statusCode)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user