mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
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:
51
open-sse/services/claudeUsageCooldown.ts
Normal file
51
open-sse/services/claudeUsageCooldown.ts
Normal 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();
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
66
tests/unit/claude-oauth-usage-429-backoff.test.ts
Normal file
66
tests/unit/claude-oauth-usage-429-backoff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user