From 7488c527df2efefc96655f5ff6f055bef428ade1 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Tue, 21 Apr 2026 18:17:22 +0900 Subject: [PATCH 1/4] fix(runtime-fallback): trigger fallback on quota/credit exhaustion (fixes #3519) --- src/hooks/runtime-fallback/error-classifier.ts | 7 +++---- .../quota-error-classifier.regression.test.ts | 15 +++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 7ba5aa491..6b9132588 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -169,10 +169,9 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole } if (errorType === "quota_exceeded") { - // When a provider signals an auto-retry (e.g. "retrying in ~2 weeks"), - // we should still trigger fallback to another model rather than STOP. - const hasAutoRetrySignal = /retrying\s+in/i.test(message) - return hasAutoRetrySignal + // Quota exhaustion means the current model/provider cannot serve requests. + // Trigger fallback to the next configured model instead of stopping entirely. + return true } if (statusCode && retryOnErrors.includes(statusCode)) { 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 0caa816a3..1979ddc30 100644 --- a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test" import { classifyErrorType, isRetryableError } from "./error-classifier" describe("runtime-fallback quota error regressions", () => { - test("classifies subscription quota errors as quota_exceeded and stops retry", () => { + test("classifies subscription quota errors as quota_exceeded and triggers fallback", () => { //#given const error = { name: "AI_APICallError", @@ -16,10 +16,11 @@ describe("runtime-fallback quota error regressions", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota exhaustion should trigger fallback to the next model + expect(retryable).toBe(true) }) - test("treats HTTP 402 payment required as non-retryable", () => { + test("treats HTTP 402 payment required as fallback-eligible", () => { //#given const error = { statusCode: 402, message: "Payment Required" } @@ -27,7 +28,8 @@ describe("runtime-fallback quota error regressions", () => { const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) //#then - expect(retryable).toBe(false) + // payment failure triggers fallback to a different provider/model + expect(retryable).toBe(true) }) test("keeps HTTP 429 rate limit retryable", () => { @@ -41,7 +43,7 @@ describe("runtime-fallback quota error regressions", () => { expect(retryable).toBe(true) }) - test("classifies quota error names as quota_exceeded without retry", () => { + test("classifies quota error names as quota_exceeded and triggers fallback", () => { //#given const error = { name: "QuotaExceededError", message: "Request failed." } @@ -51,6 +53,7 @@ describe("runtime-fallback quota error regressions", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota errors trigger fallback to next configured model + expect(retryable).toBe(true) }) }) From fbf130ec3a1e7cdfe486cf4a9697da1d22de78fa Mon Sep 17 00:00:00 2001 From: MoerAI Date: Tue, 21 Apr 2026 18:30:04 +0900 Subject: [PATCH 2/4] fix(test): update integration tests to expect fallback on quota exhaustion --- src/hooks/runtime-fallback/index.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 76e29c2d3..d96f6d211 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -293,7 +293,7 @@ describe("runtime-fallback", () => { expect(errorLog).toBeDefined() }) - test("should NOT trigger fallback for quota exhaustion without auto-retry signal (STOP classification)", async () => { + test("should trigger fallback for quota exhaustion to try next configured model", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig({ notify_on_fallback: false }), pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]), @@ -318,11 +318,9 @@ describe("runtime-fallback", () => { }, }) + // quota exhaustion now triggers fallback to the next model const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback")) - expect(fallbackLog).toBeUndefined() - - const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable")) - expect(skipLog).toBeDefined() + expect(fallbackLog).toBeDefined() }) test("should continue fallback chain when fallback model is not found", async () => { @@ -2071,7 +2069,7 @@ describe("runtime-fallback", () => { expect(retriedModels).toContain("openai/gpt-5.3-codex") }) - test("does NOT trigger fallback for quota exhaustion in error parts without auto-retry signal (STOP classification)", async () => { + test("triggers fallback for quota exhaustion in error parts to try next model", async () => { const retriedModels: string[] = [] const hook = createRuntimeFallbackHook( @@ -2119,10 +2117,8 @@ describe("runtime-fallback", () => { }, }) - expect(retriedModels).toHaveLength(0) - - const skipLog = logCalls.find((c) => c.msg.includes("message.updated error not retryable")) - expect(skipLog).toBeDefined() + // quota exhaustion now triggers fallback to next configured model + expect(retriedModels.length).toBeGreaterThanOrEqual(1) }) test("triggers fallback when message has mixed text and error parts", async () => { From 59493002e4c1e88a9ba0436c80eecf260030d578 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 22 Apr 2026 18:47:16 +0900 Subject: [PATCH 3/4] fix(error-classifier): match insufficient balance/funds as quota_exceeded --- 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 6b9132588..f3afccc31 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -127,7 +127,7 @@ export function classifyErrorType(error: unknown): string | undefined { errorName?.includes("billingerror") || /quota.?exceeded/i.test(message) || /subscription.*quota/i.test(message) || - /insufficient.?quota/i.test(message) || + /insufficient.?(?:quota|balance|funds?)/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) || From 328df08162308c6855cd5e0ca17a4adfcbd0bd2b Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 22 Apr 2026 19:10:49 +0900 Subject: [PATCH 4/4] fix(test): update provider-matrix tests to expect fallback on quota exhaustion --- .../runtime-fallback/provider-matrix.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/hooks/runtime-fallback/provider-matrix.test.ts b/src/hooks/runtime-fallback/provider-matrix.test.ts index 727b2967d..db7b89ade 100644 --- a/src/hooks/runtime-fallback/provider-matrix.test.ts +++ b/src/hooks/runtime-fallback/provider-matrix.test.ts @@ -18,7 +18,8 @@ describe("runtime-fallback provider matrix quota tests", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota exhaustion triggers fallback to next configured model + expect(retryable).toBe(true) }) test("classifies OpenAI billing_hard_limit error as quota_exceeded", () => { @@ -54,7 +55,7 @@ describe("runtime-fallback provider matrix quota tests", () => { }) describe("Anthropic provider", () => { - test("classifies Anthropic quota exceeded as non-retryable", () => { + test("classifies Anthropic quota exceeded as retryable to trigger fallback", () => { //#given const error = { name: "QuotaExceededError", @@ -68,10 +69,11 @@ describe("runtime-fallback provider matrix quota tests", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota exhaustion triggers fallback to next configured model + expect(retryable).toBe(true) }) - test("classifies Anthropic subscription quota as non-retryable", () => { + test("classifies Anthropic subscription quota as retryable to trigger fallback", () => { //#given const error = { name: "AI_APICallError", @@ -85,7 +87,8 @@ describe("runtime-fallback provider matrix quota tests", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota exhaustion triggers fallback to next configured model + expect(retryable).toBe(true) }) test("classifies Anthropic cooling down with retry signal as retryable (auto-retry pattern)", () => { @@ -139,7 +142,8 @@ describe("runtime-fallback provider matrix quota tests", () => { //#then expect(errorType).toBe("quota_exceeded") - expect(retryable).toBe(false) + // quota exhaustion triggers fallback to next configured model + expect(retryable).toBe(true) }) test("classifies Google rate limit exceeded as retryable", () => { @@ -274,7 +278,7 @@ describe("runtime-fallback provider matrix quota tests", () => { expect(retryable).toBe(true) }) - test("402 payment required is NOT retryable", () => { + test("402 payment required triggers fallback via quota_exceeded", () => { //#given const error = { statusCode: 402, message: "Payment Required" } @@ -282,7 +286,8 @@ describe("runtime-fallback provider matrix quota tests", () => { const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) //#then - expect(retryable).toBe(false) + // payment required is classified as quota_exceeded, which triggers fallback + expect(retryable).toBe(true) }) test("500 server error is retryable", () => {