mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -1578,7 +1578,8 @@ export function checkFallbackError(
|
||||
const subResult = buildSubscriptionQuotaFallback(
|
||||
errorStr,
|
||||
getUpstreamRetryHintMs,
|
||||
parseRetryFromErrorText
|
||||
parseRetryFromErrorText,
|
||||
provider
|
||||
);
|
||||
if (subResult) return subResult;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user