fix(claude): classify native subscription quota 429 (#8628)

Co-authored-by: Escalada Online <aescaladaonline@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Éder Costa
2026-07-27 19:08:05 -03:00
committed by GitHub
parent dd22d9c017
commit 9108955323
4 changed files with 36 additions and 6 deletions

View File

@@ -1578,7 +1578,8 @@ export function checkFallbackError(
const subResult = buildSubscriptionQuotaFallback(
errorStr,
getUpstreamRetryHintMs,
parseRetryFromErrorText
parseRetryFromErrorText,
provider
);
if (subResult) return subResult;
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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(