From f357ed033a8b4ec6564e453a1f64378e186462d3 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sun, 17 May 2026 10:28:05 -0400 Subject: [PATCH 1/3] fix(runtime-fallback): classify more provider quota error names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../runtime-fallback/error-classifier.ts | 9 +++- .../quota-error-classifier.regression.test.ts | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 33b17ccf3..1f18b93ad 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -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) || diff --git a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts index f55c632cf..1a26bfb89 100644 --- a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -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) + }) }) From b2f0d42394205cfc18a5ed975d5c5eeacd0bcd89 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sun, 17 May 2026 10:37:40 -0400 Subject: [PATCH 2/3] test(runtime-fallback): tighten quota regression fixtures so new paths actually fire Addresses cubic-dev-ai bot review on #4113 (P2): the RESOURCE_EXHAUSTED and snake_case insufficient_quota fixtures contained quota-shaped messages that already matched pre-existing message regexes, so the tests passed even without the new errorName allow-list entry and the underscore normalization respectively. Replace both fixture messages with a generic "Request failed." so the only path to a `quota_exceeded` classification is via the new code: - RESOURCE_EXHAUSTED: only the new `errorName?.includes("resourceexhausted")` match on the normalized name can fire. - insufficient_quota (snake_case): only the new underscore-stripping normalization can route the name to `insufficientquota` and match the existing allow-list entry. The third new test (Google ResourceExhausted message-only) is unchanged because its message uniquely matches only the new `/resource.?exhausted/i` pattern and not any existing quota regex. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quota-error-classifier.regression.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts index 1a26bfb89..9657af523 100644 --- a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -90,11 +90,14 @@ describe("runtime-fallback quota error regressions", () => { expect(retryable).toBe(true) }) - test("classifies Google RESOURCE_EXHAUSTED (gRPC code 8) as quota_exceeded", () => { + test("classifies Google RESOURCE_EXHAUSTED (gRPC code 8) as quota_exceeded via error name only", () => { //#given + // Bare provider error: only the error name carries the quota signal. + // Message is intentionally generic so the test fails if the new + // `resourceexhausted` name allow-list entry is removed. const error = { name: "RESOURCE_EXHAUSTED", - message: "Quota exceeded for quota metric 'Generate Content' and limit 'Generate Content quota per minute'.", + message: "Request failed.", } //#when @@ -122,11 +125,14 @@ describe("runtime-fallback quota error regressions", () => { expect(retryable).toBe(true) }) - test("classifies snake_case OpenAI insufficient_quota error name as quota_exceeded", () => { + test("classifies snake_case OpenAI insufficient_quota error name as quota_exceeded via name only", () => { //#given + // Bare provider error: only the snake_case error name carries the quota signal. + // Message is intentionally generic so the test fails if the underscore + // normalization (`insufficient_quota` -> `insufficientquota`) regresses. const error = { name: "insufficient_quota", - message: "You exceeded your current quota, please check your plan and billing details.", + message: "Request failed.", } //#when From a8ccffdd7c7a91300c5bd25b66607b4e17eaf474 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sun, 17 May 2026 11:05:01 -0400 Subject: [PATCH 3/3] style(runtime-fallback): add explicit optional chain on .replace per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cubic-dev-ai P1 finding on #4113 (#4113 review). The original chain `extractErrorName(error)?.toLowerCase().replace(...)` is semantically safe — JavaScript optional chaining short-circuits the ENTIRE access chain when the head returns null/undefined, so when `extractErrorName` returns undefined the whole expression evaluates to undefined without ever reaching `.replace()`. Verified empirically via `const x = undefined; x?.toLowerCase().replace(/_/g, "")` returns undefined with no crash. Applying the suggested defensive `?.` before `.replace` anyway, since it is semantically a no-op and explicit chaining at each hop is easier for static analyzers to reason about. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/runtime-fallback/error-classifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 1f18b93ad..6ba155101 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -109,7 +109,7 @@ export function classifyErrorType(error: unknown): string | undefined { // 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, "") + const errorName = extractErrorName(error)?.toLowerCase()?.replace(/[_-]/g, "") if ( errorName?.includes("ailoadapikeyerror") ||