Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,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)
Expand Down
13 changes: 8 additions & 5 deletions src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/account-fallback-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/classify429.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down