From 91089553233de6d60b3f8cd7f3f39ea70d14eb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Mon, 27 Jul 2026 19:08:05 -0300 Subject: [PATCH] fix(claude): classify native subscription quota 429 (#8628) Co-authored-by: Escalada Online Co-authored-by: diegosouzapw --- open-sse/services/accountFallback.ts | 3 ++- open-sse/services/quotaTextCooldowns.ts | 14 +++++++++---- src/sse/handlers/chat.ts | 5 ++++- .../account-fallback-anthropic-quota.test.ts | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 9fc528ee29..a53bdffef6 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1578,7 +1578,8 @@ export function checkFallbackError( const subResult = buildSubscriptionQuotaFallback( errorStr, getUpstreamRetryHintMs, - parseRetryFromErrorText + parseRetryFromErrorText, + provider ); if (subResult) return subResult; } diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts index 4f481b822d..c0e1a17ec6 100644 --- a/open-sse/services/quotaTextCooldowns.ts +++ b/open-sse/services/quotaTextCooldowns.ts @@ -28,13 +28,18 @@ export interface QuotaTextFallback { // for the 5-hour subscription quota. Without a dedicated branch the request // falls through to the generic 429 retry path (~5s base cooldown). -export function isSubscriptionQuotaText(lower: string): boolean { +export function isSubscriptionQuotaText(lower: string, provider?: string | null): boolean { return ( lower.includes("usage limit reached") || lower.includes("usage limit has been") || lower.includes("claude pro usage limit") || lower.includes("you've reached your usage limit") || - lower.includes("you have reached your usage limit") + lower.includes("you have reached your usage limit") || + // Native Claude OAuth uses this otherwise-generic 429 wording for an + // exhausted subscription window. Keep it provider-scoped: other upstreams + // can use the same phrase for a short RPM throttle. + (provider === "claude" && + lower.includes("this request would exceed your account's rate limit")) ); } @@ -55,9 +60,10 @@ const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour export function buildSubscriptionQuotaFallback( errorStr: string, getUpstreamRetryHintMs: () => number | null, - parseRetryFromErrorText: (text: string) => number | null + parseRetryFromErrorText: (text: string) => number | null, + provider?: string | null ): QuotaTextFallback | null { - if (!isSubscriptionQuotaText(errorStr.toLowerCase())) return null; + if (!isSubscriptionQuotaText(errorStr.toLowerCase(), provider)) return null; const hintMs = getUpstreamRetryHintMs(); const bodyHint = parseRetryFromErrorText(errorStr); return { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index c447b60f23..1eedbe86e2 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -95,6 +95,7 @@ import { getComboFailureLogError } from "./comboFailureLogging"; // Pipeline integration — wired modules import { classify429FromError, type FailureKind } from "@/shared/utils/classify429"; +import { isSubscriptionQuotaText } from "@omniroute/open-sse/services/quotaTextCooldowns.ts"; import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints"; import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; @@ -1725,7 +1726,9 @@ async function handleSingleModelChat( const errorStr = String(result.rawMessage ?? result.error ?? ""); const failureKind = result.status === 429 - ? classify429FromError({ status: result.status, message: errorStr }) + ? isSubscriptionQuotaText(errorStr.toLowerCase(), provider) + ? "quota_exhausted" + : classify429FromError({ status: result.status, message: errorStr }) : undefined; if (result.status === 429 && isDailyQuotaExhausted(errorStr)) { // Parse which model is quota-limited diff --git a/tests/unit/account-fallback-anthropic-quota.test.ts b/tests/unit/account-fallback-anthropic-quota.test.ts index 978641ece2..7da4cfae2a 100644 --- a/tests/unit/account-fallback-anthropic-quota.test.ts +++ b/tests/unit/account-fallback-anthropic-quota.test.ts @@ -10,6 +10,8 @@ import assert from "node:assert/strict"; const { classifyErrorText, parseRetryFromErrorText, checkFallbackError, getProviderProfile } = await import("../../open-sse/services/accountFallback.ts"); +const { isSubscriptionQuotaText } = + await import("../../open-sse/services/quotaTextCooldowns.ts"); const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); test("#2321 classifyErrorText flags 'Usage Limit Reached' as QUOTA_EXHAUSTED", () => { @@ -34,6 +36,12 @@ test("#2321 classifyErrorText still returns RATE_LIMIT_EXCEEDED for generic rate assert.equal(out, RateLimitReason.RATE_LIMIT_EXCEEDED); }); +test("Claude native account rate-limit text is subscription quota only for Claude", () => { + const text = "This request would exceed your account's rate limit. Please try again later."; + assert.equal(isSubscriptionQuotaText(text.toLowerCase(), "claude"), true); + assert.equal(isSubscriptionQuotaText(text.toLowerCase(), "antigravity"), false); +}); + test("#2321 parseRetryFromErrorText extracts an ISO timestamp", () => { const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); const ms = parseRetryFromErrorText(`Usage Limit Reached. Try again at ${future}`); @@ -70,6 +78,18 @@ test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit ); }); +test("Claude native account rate-limit text gets subscription quota cooldown", () => { + const out = checkFallbackError( + 429, + "This request would exceed your account's rate limit. Please try again later.", + 0, + null, + "claude" + ); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.ok(out.cooldownMs >= 5 * 60 * 1000, `expected long cooldown, got ${out.cooldownMs}ms`); +}); + test("#2321 checkFallbackError ignores ISO timestamp when upstream retry hints are disabled", () => { const future = new Date(Date.now() + 45 * 60 * 1000).toISOString(); const out = checkFallbackError(