diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index dbc087252d..12271a22cf 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -474,8 +474,14 @@ export function lockModelIfPerModelQuota( export function shouldMarkAccountExhaustedFrom429( provider: string | null | undefined, model: string | null | undefined = null, - connectionPassthroughModels?: boolean + connectionPassthroughModels?: boolean, + failureKind?: FailureKind ): boolean { + // A plain 429 means transient rate limiting / high traffic for many OAuth providers. + // Only connection-poison the quota cache when the upstream body explicitly says + // the long-window quota is exhausted; otherwise fallback should try another account + // without making this one look quota-depleted for 5 minutes. + if (failureKind === "rate_limit" || failureKind === "transient") return false; return ( shouldPreserveQuotaSignalsFor429(provider) && !hasPerModelQuota(provider, model, connectionPassthroughModels) diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index e816960249..9a9bb63a90 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1064,15 +1064,18 @@ async function handleSingleModelChat( dailyQuotaExhausted = true; } - // 7. Mark account as quota-exhausted on 429 response (non-daily-quota errors) - // For providers that route quota/cooldown at model scope, a 429 on one model - // does not mean the whole connection is exhausted. - // Daily quota errors are handled above; only process regular rate_limit here + // 7. Mark account as quota-exhausted only for explicit long-window quota signals. + // A plain 429/high-traffic response should trigger fallback/cooldown, not poison + // quotaCache as exhausted for 5 minutes while usage quota may still be available. if (!dailyQuotaExhausted) { const passthroughModels = credentials.providerSpecificData?.passthroughModels; + const failureKind = + result.status === 429 + ? classify429FromError({ status: result.status, message: errorStr }) + : undefined; if ( result.status === 429 && - shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels) + shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) ) { markAccountExhaustedFrom429(credentials.connectionId, provider); } diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index d3dac846d1..dc153c37d0 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -295,6 +295,21 @@ test("shouldMarkAccountExhaustedFrom429 skips connection poisoning for compatibl assert.equal(shouldMarkAccountExhaustedFrom429("claude", "claude-sonnet-4-6"), true); }); +test("shouldMarkAccountExhaustedFrom429 does not poison quota cache for transient 429s", () => { + assert.equal( + shouldMarkAccountExhaustedFrom429("kiro", "claude-opus-4.7", undefined, "rate_limit"), + false + ); + assert.equal( + shouldMarkAccountExhaustedFrom429("kiro", "claude-opus-4.7", undefined, "transient"), + false + ); + assert.equal( + shouldMarkAccountExhaustedFrom429("kiro", "claude-opus-4.7", undefined, "quota_exhausted"), + true + ); +}); + test("hasPerModelQuota returns true for GitHub Copilot provider (#1624)", () => { assert.equal(hasPerModelQuota("github"), true); assert.equal(hasPerModelQuota("github", "gpt-5.1-codex-max"), true); diff --git a/tests/unit/classify429.test.ts b/tests/unit/classify429.test.ts index 75d1a4fbd3..a57b04e134 100644 --- a/tests/unit/classify429.test.ts +++ b/tests/unit/classify429.test.ts @@ -66,6 +66,13 @@ test("classify429: 429 without quota keyword returns 'rate_limit'", () => { }), "rate_limit" ); + assert.equal( + classify429({ + status: 429, + body: "I am experiencing high traffic, please try again shortly.", + }), + "rate_limit" + ); }); test("looksLikeQuotaExhausted: detects all known keyword variants", () => {