fix(runtime-fallback): classify more provider quota error names

Closes #3937.

Adds three small classification gaps to `classifyErrorType` so that
quota-exhaustion errors from a wider range of providers trigger
configured fallback chains instead of looping retry attempts:

- Normalize error names by stripping `_` and `-` so snake_case /
  SCREAMING_SNAKE_CASE provider names (`insufficient_quota`,
  `RESOURCE_EXHAUSTED`, `rate_limit_exceeded`) match the existing
  alphanumeric `.includes()` checks.
- Add `resourceexhausted` to the quota error-name allow-list to cover
  Google Generative AI's gRPC code 8 / `ResourceExhausted` surface.
- Add `/resource.?exhausted/i` to the quota message-pattern list so the
  same error surface is caught when the provider only sets a generic
  error name but puts the signal in the message.

Three new regression tests in
`quota-error-classifier.regression.test.ts` cover:

- Google `RESOURCE_EXHAUSTED` (gRPC error name + quota-shaped message)
- Google `ResourceExhausted` message form without HTTP status
- OpenAI snake_case `insufficient_quota` error name

No existing tests were touched; the underscore normalization preserves
all existing `.includes()` matches by rewriting the one underscore-bearing
literal (`ai_loadapikeyerror` → `ailoadapikeyerror`) so previously
matched names still resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeyuFu
2026-05-17 10:28:05 -04:00
parent babee921b2
commit f357ed033a
2 changed files with 55 additions and 2 deletions
@@ -106,10 +106,13 @@ function isLocalizedQuotaExhaustionMessage(message: string): boolean {
export function classifyErrorType(error: unknown): string | undefined {
const message = getErrorMessage(error)
const errorName = extractErrorName(error)?.toLowerCase()
// Normalize by stripping underscores and dashes so snake_case / kebab-case
// provider error names (e.g. "insufficient_quota", "RESOURCE_EXHAUSTED")
// match the existing alphanumeric .includes() checks below.
const errorName = extractErrorName(error)?.toLowerCase().replace(/[_-]/g, "")
if (
errorName?.includes("ai_loadapikeyerror") ||
errorName?.includes("ailoadapikeyerror") ||
errorName?.includes("loadapi") ||
(/api.?key.?is.?missing/i.test(message) && /environment variable/i.test(message))
) {
@@ -132,6 +135,7 @@ export function classifyErrorType(error: unknown): string | undefined {
errorName?.includes("quotaexceeded") ||
errorName?.includes("insufficientquota") ||
errorName?.includes("billingerror") ||
errorName?.includes("resourceexhausted") ||
/quota.?exceeded/i.test(message) ||
/exceeded.*quota/i.test(message) ||
/usage\s*quota/i.test(message) ||
@@ -139,6 +143,7 @@ export function classifyErrorType(error: unknown): string | undefined {
/insufficient.?(?:quota|balance|funds?)/i.test(message) ||
/billing.?(?:hard.?)?limit/i.test(message) ||
/exhausted\s+your\s+capacity/i.test(message) ||
/resource.?exhausted/i.test(message) ||
/out\s+of\s+credits?/i.test(message) ||
/payment.?required/i.test(message) ||
/usage\s+limit/i.test(message) ||
@@ -89,4 +89,52 @@ describe("runtime-fallback quota error regressions", () => {
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
test("classifies Google RESOURCE_EXHAUSTED (gRPC code 8) as quota_exceeded", () => {
//#given
const error = {
name: "RESOURCE_EXHAUSTED",
message: "Quota exceeded for quota metric 'Generate Content' and limit 'Generate Content quota per minute'.",
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
test("classifies Google ResourceExhausted message without HTTP status as quota_exceeded", () => {
//#given
const error = {
name: "GoogleGenerativeAIError",
message: "Resource exhausted: Please try again later.",
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
test("classifies snake_case OpenAI insufficient_quota error name as quota_exceeded", () => {
//#given
const error = {
name: "insufficient_quota",
message: "You exceeded your current quota, please check your plan and billing details.",
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(true)
})
})