Files
OmniRoute/tests/unit/account-fallback-anthropic-quota.test.ts
diegosouzapw d62b128144 fix(account-fallback): classify Anthropic 'Usage Limit Reached' as quota (#2321)
Claude Code Pro/Team users hit a 429 cascade where every Claude account
got marked rate-limited for only a few seconds, retried, hit 429 again,
and the cycle exhausted all accounts until the 5h subscription window
finally reset. Root cause: Anthropic OAuth 429 bodies carry phrases like
'Usage Limit Reached' or 'Claude Pro usage limit reached' that don't
contain the word 'quota'. classifyErrorText fell through to
RATE_LIMIT_EXCEEDED with the OAuth ~5s base cooldown.

Three targeted changes in open-sse/services/accountFallback.ts:

1. classifyErrorText: recognize the Anthropic OAuth phrases ('usage limit
   reached', 'claude pro usage limit', 'you've reached your usage limit'
   and variants) and return QUOTA_EXHAUSTED.

2. checkFallbackError: new branch that, when the classifier flags
   QUOTA_EXHAUSTED for a non-credits/non-daily case, applies a deliberate
   1h cooldown (subscription quotas need real time to reset; the existing
   COOLDOWN_MS.paymentRequired is only 2 minutes).

3. parseRetryFromErrorText: parse absolute ISO 8601 timestamps in the
   body ('Try again at 2026-05-17T10:00:00Z', 'Please wait until ...').
   When present, honor the upstream's stated recovery time instead of
   the 1h default.

Cross-provider fallback for direct (non-combo) calls remains a separate
feature — operators wanting that should route Claude through a combo
that includes openrouter/claude or another fallback target.
2026-05-18 09:11:32 -03:00

101 lines
4.1 KiB
TypeScript

/**
* Issue #2321 — Anthropic OAuth (Claude Pro/Team) 429 responses with phrases
* like "Usage Limit Reached" must be classified as QUOTA_EXHAUSTED with a
* long cooldown, not as a generic RATE_LIMIT_EXCEEDED with a ~5s base
* cooldown. Without this fix all Pro accounts on the same subscription
* cascade into a tight retry loop until the 5h quota window resets.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { classifyErrorText, parseRetryFromErrorText, checkFallbackError } =
await import("../../open-sse/services/accountFallback.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
test("#2321 classifyErrorText flags 'Usage Limit Reached' as QUOTA_EXHAUSTED", () => {
const out = classifyErrorText("Usage Limit Reached. Please wait until 10:00 AM");
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
});
test("#2321 classifyErrorText flags 'Claude Pro usage limit reached' as QUOTA_EXHAUSTED", () => {
const out = classifyErrorText("Claude Pro usage limit reached.");
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
});
test("#2321 classifyErrorText flags possessive 'you've reached your usage limit'", () => {
const out = classifyErrorText("Sorry — you've reached your usage limit for this model.");
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
});
test("#2321 classifyErrorText still returns RATE_LIMIT_EXCEEDED for generic rate_limit", () => {
// Regression guard: short-term rate limits (per-minute TPM) must remain
// transient so we don't lock accounts for an hour after a normal burst.
const out = classifyErrorText("rate_limit_exceeded: too many requests");
assert.equal(out, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
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}`);
assert.ok(ms !== null, "expected non-null wait time");
assert.ok(ms! > 30 * 60 * 1000 && ms! <= 60 * 60 * 1000, `expected ~1h wait, got ${ms}ms`);
});
test("#2321 parseRetryFromErrorText ignores past ISO timestamps", () => {
const past = "2020-01-01T00:00:00Z";
const ms = parseRetryFromErrorText(`Try again at ${past}`);
assert.equal(ms, null);
});
test("#2321 parseRetryFromErrorText still handles the 'reset after Xh' format (backward compat)", () => {
const ms = parseRetryFromErrorText("Your quota will reset after 1h30m");
assert.equal(ms, (60 + 30) * 60 * 1000);
});
test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit Reached", () => {
const out = checkFallbackError(
429,
"Usage Limit Reached. Please wait until 5h.",
0,
null,
"claude" // OAuth provider
);
assert.equal(out.shouldFallback, true);
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
// Either the 1h default OR an upstream retry hint — both are far above
// the ~5s base cooldown that caused the cascade.
assert.ok(
out.cooldownMs >= 5 * 60 * 1000,
`expected long cooldown (>=5min), got ${out.cooldownMs}ms`
);
});
test("#2321 checkFallbackError honors ISO timestamp from body when present", () => {
const futureMs = 45 * 60 * 1000;
const future = new Date(Date.now() + futureMs).toISOString();
const out = checkFallbackError(
429,
`Claude Pro usage limit reached. Try again at ${future}`,
0,
null,
"claude"
);
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
// Within ~30s of the requested wait time.
assert.ok(
Math.abs(out.cooldownMs - futureMs) < 30_000,
`expected ~${futureMs}ms cooldown, got ${out.cooldownMs}ms`
);
});
test("#2321 generic 429 without quota keyword still gets the short cooldown path", () => {
// Regression guard: plain rate-limit must NOT get the 1h cooldown.
const out = checkFallbackError(429, "rate_limit_exceeded", 0, null, "claude");
assert.equal(out.shouldFallback, true);
// Generic 429 keeps the short retryable path (< 5 min).
assert.ok(
out.cooldownMs < 5 * 60 * 1000,
`expected short cooldown (<5min), got ${out.cooldownMs}ms`
);
});