Merge pull request #3556 from MoerAI/fix/fallback-credit-exhaustion

Fixes #3519. Quota exhaustion now advances the runtime fallback chain instead of stopping. Surgical change with parallel test updates. Pre-existing prompt-text test mismatch on the older base disappeared after rebasing onto current dev — all checks green.
This commit is contained in:
YeonGyu-Kim
2026-04-29 14:43:13 +09:00
committed by GitHub
4 changed files with 32 additions and 29 deletions
@@ -127,7 +127,7 @@ export function classifyErrorType(error: unknown): string | undefined {
errorName?.includes("billingerror") || errorName?.includes("billingerror") ||
/quota.?exceeded/i.test(message) || /quota.?exceeded/i.test(message) ||
/subscription.*quota/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) || /billing.?(?:hard.?)?limit/i.test(message) ||
/exhausted\s+your\s+capacity/i.test(message) || /exhausted\s+your\s+capacity/i.test(message) ||
/out\s+of\s+credits?/i.test(message) || /out\s+of\s+credits?/i.test(message) ||
@@ -169,10 +169,9 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
} }
if (errorType === "quota_exceeded") { if (errorType === "quota_exceeded") {
// When a provider signals an auto-retry (e.g. "retrying in ~2 weeks"), // Quota exhaustion means the current model/provider cannot serve requests.
// we should still trigger fallback to another model rather than STOP. // Trigger fallback to the next configured model instead of stopping entirely.
const hasAutoRetrySignal = /retrying\s+in/i.test(message) return true
return hasAutoRetrySignal
} }
if (statusCode && retryOnErrors.includes(statusCode)) { if (statusCode && retryOnErrors.includes(statusCode)) {
+6 -10
View File
@@ -293,7 +293,7 @@ describe("runtime-fallback", () => {
expect(errorLog).toBeDefined() 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(), { const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }), config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]), 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")) const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeUndefined() expect(fallbackLog).toBeDefined()
const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable"))
expect(skipLog).toBeDefined()
}) })
test("should continue fallback chain when fallback model is not found", async () => { 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") 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 retriedModels: string[] = []
const hook = createRuntimeFallbackHook( const hook = createRuntimeFallbackHook(
@@ -2119,10 +2117,8 @@ describe("runtime-fallback", () => {
}, },
}) })
expect(retriedModels).toHaveLength(0) // quota exhaustion now triggers fallback to next configured model
expect(retriedModels.length).toBeGreaterThanOrEqual(1)
const skipLog = logCalls.find((c) => c.msg.includes("message.updated error not retryable"))
expect(skipLog).toBeDefined()
}) })
test("triggers fallback when message has mixed text and error parts", async () => { test("triggers fallback when message has mixed text and error parts", async () => {
@@ -18,7 +18,8 @@ describe("runtime-fallback provider matrix quota tests", () => {
//#then //#then
expect(errorType).toBe("quota_exceeded") 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", () => { test("classifies OpenAI billing_hard_limit error as quota_exceeded", () => {
@@ -54,7 +55,7 @@ describe("runtime-fallback provider matrix quota tests", () => {
}) })
describe("Anthropic provider", () => { describe("Anthropic provider", () => {
test("classifies Anthropic quota exceeded as non-retryable", () => { test("classifies Anthropic quota exceeded as retryable to trigger fallback", () => {
//#given //#given
const error = { const error = {
name: "QuotaExceededError", name: "QuotaExceededError",
@@ -68,10 +69,11 @@ describe("runtime-fallback provider matrix quota tests", () => {
//#then //#then
expect(errorType).toBe("quota_exceeded") 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 //#given
const error = { const error = {
name: "AI_APICallError", name: "AI_APICallError",
@@ -85,7 +87,8 @@ describe("runtime-fallback provider matrix quota tests", () => {
//#then //#then
expect(errorType).toBe("quota_exceeded") 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)", () => { 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 //#then
expect(errorType).toBe("quota_exceeded") 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", () => { test("classifies Google rate limit exceeded as retryable", () => {
@@ -274,7 +278,7 @@ describe("runtime-fallback provider matrix quota tests", () => {
expect(retryable).toBe(true) expect(retryable).toBe(true)
}) })
test("402 payment required is NOT retryable", () => { test("402 payment required triggers fallback via quota_exceeded", () => {
//#given //#given
const error = { statusCode: 402, message: "Payment Required" } 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]) const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then //#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", () => { test("500 server error is retryable", () => {
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
import { classifyErrorType, isRetryableError } from "./error-classifier" import { classifyErrorType, isRetryableError } from "./error-classifier"
describe("runtime-fallback quota error regressions", () => { 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 //#given
const error = { const error = {
name: "AI_APICallError", name: "AI_APICallError",
@@ -16,10 +16,11 @@ describe("runtime-fallback quota error regressions", () => {
//#then //#then
expect(errorType).toBe("quota_exceeded") 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 //#given
const error = { statusCode: 402, message: "Payment Required" } 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]) const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then //#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", () => { test("keeps HTTP 429 rate limit retryable", () => {
@@ -41,7 +43,7 @@ describe("runtime-fallback quota error regressions", () => {
expect(retryable).toBe(true) 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 //#given
const error = { name: "QuotaExceededError", message: "Request failed." } const error = { name: "QuotaExceededError", message: "Request failed." }
@@ -51,6 +53,7 @@ describe("runtime-fallback quota error regressions", () => {
//#then //#then
expect(errorType).toBe("quota_exceeded") expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(false) // quota errors trigger fallback to next configured model
expect(retryable).toBe(true)
}) })
}) })