diff --git a/open-sse/services/claudeUsageCooldown.ts b/open-sse/services/claudeUsageCooldown.ts new file mode 100644 index 0000000000..01de93dec9 --- /dev/null +++ b/open-sse/services/claudeUsageCooldown.ts @@ -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(); + +/** 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(); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index edd14b8a09..5be8ab2692 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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/` // (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` diff --git a/tests/unit/claude-oauth-usage-429-backoff.test.ts b/tests/unit/claude-oauth-usage-429-backoff.test.ts new file mode 100644 index 0000000000..3406421a8b --- /dev/null +++ b/tests/unit/claude-oauth-usage-429-backoff.test.ts @@ -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); + }); +});