fix(claude-oauth): respect 429 backoff on usage endpoint to reduce spam

Anthropic rate-limits the OAuth quota endpoint
(`/api/oauth/usage`) independently of `/v1/messages`. When several
connections polled at once, the dashboard hammered it and surfaced
noisy 429s. After a 429 we now cool down OAuth usage polling for that
access token (3 min) and transparently fall back to the legacy
settings/org endpoint — chat with the same token is unaffected.

The cooldown lives in a small pure helper module
(`open-sse/services/claudeUsageCooldown.ts`) so the policy is
TDD-covered without touching fetch.

Co-authored-by: decolua <decoluadt@example.com>
Inspired-by: https://github.com/decolua/9router/commit/79df34ca
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-22 12:28:58 -03:00
parent 607ad12e84
commit 4a734734c4
3 changed files with 132 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
/**
* Per-token cooldown for the Claude OAuth usage endpoint
* (`https://api.anthropic.com/api/oauth/usage`).
*
* Anthropic rate-limits this quota endpoint independently of `/v1/messages`.
* When polled from multiple connections at once (dashboard auto-refresh + the
* combo health-scheduler), it spams `429` and that surfaces as noisy provider
* errors plus increased upstream load. Chat with the same token still works.
*
* We track a per-access-token "skip until" timestamp. When we see a `429`,
* we suppress further OAuth-usage polls for that token until the cooldown
* expires and the caller falls back to the legacy settings/org endpoint
* (which is what `getClaudeUsage` does on every non-OK response).
*
* Pure helpers (no fetch, no timers) keep this TDD-friendly.
*
* Inspired-by upstream 9router commit `79df34ca`.
*/
export const OAUTH_USAGE_429_COOLDOWN_MS = 180_000; // 3 minutes
const oauthCooldown = new Map<string, number>();
/** Returns true while `accessToken` is still inside its 429 cooldown window. */
export function isClaudeOauthUsageCoolingDown(
accessToken: string | undefined,
now: number = Date.now()
): boolean {
if (!accessToken) return false;
const until = oauthCooldown.get(accessToken);
if (until === undefined) return false;
if (until > now) return true;
// Lazy GC of expired entries — keeps the Map bounded by active tokens.
oauthCooldown.delete(accessToken);
return false;
}
/** Record a 429 from the OAuth usage endpoint for `accessToken`. */
export function markClaudeOauthUsage429(
accessToken: string | undefined,
now: number = Date.now(),
cooldownMs: number = OAUTH_USAGE_429_COOLDOWN_MS
): void {
if (!accessToken) return;
oauthCooldown.set(accessToken, now + cooldownMs);
}
/** Test-only: clear all entries. */
export function _resetClaudeOauthUsageCooldown(): void {
oauthCooldown.clear();
}

View File

@@ -37,6 +37,10 @@ import {
} from "../executors/antigravity.ts";
import { getCreditsMode } from "./antigravityCredits.ts";
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts";
import {
isClaudeOauthUsageCoolingDown,
markClaudeOauthUsage429,
} from "./claudeUsageCooldown.ts";
import { generateAntigravityRequestId, getAntigravitySessionId } from "./antigravityIdentity.ts";
import {
extractCodeAssistOnboardTierId,
@@ -2564,6 +2568,12 @@ async function getClaudeUsage(accessToken?: string) {
// Refresh bootstrap in parallel; best-effort, failure non-fatal.
const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null);
// Skip OAuth usage call while this token is cooling down from a recent 429
// (chat with the same token still works — only the quota endpoint is throttled).
if (isClaudeOauthUsageCoolingDown(accessToken)) {
const legacy = await getClaudeUsageLegacy(accessToken);
return { ...legacy, bootstrap: await bootstrapPromise };
}
try {
// Real CLI uses axios here, not Stainless — UA is `claude-code/<version>`
// (not `claude-cli/...`) and the shape is simpler than /v1/messages.
@@ -2649,6 +2659,11 @@ async function getClaudeUsage(accessToken?: string) {
};
}
// Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected).
if (oauthResponse.status === 429) {
markClaudeOauthUsage429(accessToken);
}
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
console.warn(
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`

View File

@@ -0,0 +1,66 @@
import { describe, it, beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import {
OAUTH_USAGE_429_COOLDOWN_MS,
_resetClaudeOauthUsageCooldown,
isClaudeOauthUsageCoolingDown,
markClaudeOauthUsage429,
} from "../../open-sse/services/claudeUsageCooldown.ts";
describe("claude OAuth usage 429 backoff", () => {
beforeEach(() => {
_resetClaudeOauthUsageCooldown();
});
it("starts uncooled for any token", () => {
assert.equal(isClaudeOauthUsageCoolingDown("tok-a"), false);
assert.equal(isClaudeOauthUsageCoolingDown(undefined), false);
});
it("treats a single 429 as entering cooldown for that token only", () => {
const t0 = 1_000_000;
markClaudeOauthUsage429("tok-a", t0);
assert.equal(isClaudeOauthUsageCoolingDown("tok-a", t0 + 1), true);
// A different token is unaffected — cooldown is per-token, not global.
assert.equal(isClaudeOauthUsageCoolingDown("tok-b", t0 + 1), false);
});
it("repeated 429s on the same token do NOT spam: stays cooling once entered", () => {
const t0 = 2_000_000;
markClaudeOauthUsage429("tok-a", t0);
// Simulate the scheduler hitting 429 again 10s, 30s, 60s later
for (const dt of [10_000, 30_000, 60_000]) {
markClaudeOauthUsage429("tok-a", t0 + dt);
assert.equal(
isClaudeOauthUsageCoolingDown("tok-a", t0 + dt),
true,
`still cooling at +${dt}ms`
);
}
});
it("cooldown expires after OAUTH_USAGE_429_COOLDOWN_MS and the token becomes eligible again", () => {
const t0 = 3_000_000;
markClaudeOauthUsage429("tok-a", t0);
assert.equal(
isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS - 1),
true
);
assert.equal(
isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS + 1),
false,
"should be eligible again after cooldown window"
);
});
it("undefined/missing access token is a no-op (does not poison the map)", () => {
const t0 = 4_000_000;
markClaudeOauthUsage429(undefined, t0);
assert.equal(isClaudeOauthUsageCoolingDown(undefined, t0 + 1), false);
});
it("cooldown defaults to 3 minutes (matches upstream OAUTH_429_COOLDOWN_MS)", () => {
assert.equal(OAUTH_USAGE_429_COOLDOWN_MS, 180_000);
});
});