From a3c5ae5c427b812c53bcda0882fa28525670fb27 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Thu, 8 Oct 2026 07:12:33 +0200 Subject: [PATCH] fix: claude limits missing sometimes --- src/claude-quota.ts | 59 ++++++++++++----- tests/claude-quota.test.ts | 132 +++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/claude-quota.ts b/src/claude-quota.ts index c3433ee..1b74808 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -8,6 +8,8 @@ import type { QuotaSnapshot, QuotaStatus, QuotaWindow } from "./quota.js"; const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const POLL_MS = 60_000; const TIMEOUT_MS = 5_000; +const RETRY_MS = 2_000; +const RETRIES = 4; const WEEK = 604_800; type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -108,6 +110,7 @@ export class ClaudeQuotaTracker { private timer: ReturnType | undefined; private request: Promise | undefined; private controller: AbortController | undefined; + private wake: (() => void) | undefined; private disposed = false; constructor( @@ -115,6 +118,7 @@ export class ClaudeQuotaTracker { private readonly fetcher: QuotaFetch = fetch, private readonly pollMs = POLL_MS, private readonly path = claudeCredentialsPath(), + private readonly retryMs = RETRY_MS, ) {} async init() { @@ -131,22 +135,44 @@ export class ClaudeQuotaTracker { } private async query() { + for (let attempt = 0; ; attempt++) { + if (await this.attempt()) return; + if (attempt >= RETRIES || this.disposed) return; + await this.sleep(this.retryMs * 2 ** attempt); + } + } + + private sleep(ms: number) { + return new Promise((resolve) => { + const timer = setTimeout(wake, ms); + this.wake = wake; + function wake() { + clearTimeout(timer); + resolve(); + } + }); + } + + /** Returns true when done, false when the attempt failed and should be retried. */ + private async attempt() { try { - let credential: z.infer["claudeAiOauth"] | undefined; + let text: string; try { - const parsed = credentials.safeParse(JSON.parse(await readFile(this.path, "utf8"))); - credential = parsed.success ? parsed.data.claudeAiOauth : undefined; - } catch { - credential = undefined; - } - if (!credential) { - if (this.latest) { + text = await readFile(this.path, "utf8"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) + return false; + if (this.latest && !this.disposed) { this.latest = undefined; - await this.changed(); + await this.changed().catch(() => {}); } - return; + return true; } - if (credential.expiresAt <= Date.now() || this.disposed) return; + const parsed = credentials.safeParse(JSON.parse(text)); + if (!parsed.success) return false; + const credential = parsed.data.claudeAiOauth; + if (this.disposed) return true; + if (credential.expiresAt <= Date.now()) return false; const controller = new AbortController(); this.controller = controller; const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); @@ -164,19 +190,21 @@ export class ClaudeQuotaTracker { clearTimeout(timeout); if (this.controller === controller) this.controller = undefined; } - if (!response.ok) return; + if (!response.ok) return false; // SAFETY: Response.json yields JSON values; usagePayload validates fields used. const payload = (await response.json()) as Schema.Json; const snapshot = normalizeClaudeQuota( payload, credential.subscriptionType ?? "unknown", ); - if (!snapshot || this.disposed) return; + if (this.disposed) return true; + if (!snapshot) return false; this.latest = snapshot; - await this.changed(); } catch { - return; + return false; } + await this.changed().catch(() => {}); + return true; } status(): QuotaStatus | undefined { @@ -193,6 +221,7 @@ export class ClaudeQuotaTracker { async dispose() { this.disposed = true; this.controller?.abort(); + this.wake?.(); if (this.timer) clearInterval(this.timer); this.timer = undefined; await this.request; diff --git a/tests/claude-quota.test.ts b/tests/claude-quota.test.ts index 88512a4..09c267a 100644 --- a/tests/claude-quota.test.ts +++ b/tests/claude-quota.test.ts @@ -148,6 +148,7 @@ test("expired token skips fetch and keeps last snapshot", async () => { }, 60_000, path, + 1, ); await tracker.init(); await writeFile(path, credentials(Date.now() - 1)); @@ -156,3 +157,134 @@ test("expired token skips fetch and keeps last snapshot", async () => { expect(tracker.status()?.windows).toHaveLength(4); await tracker.dispose(); }); + +test("failed fetch retries with re-read credentials", async () => { + await writeFile(path, credentials(Date.now() + 3_600_000)); + const tokens: Array = []; + let changed = 0; + const tracker = new ClaudeQuotaTracker( + async () => { + changed++; + }, + async (_input, init) => { + tokens.push(new Headers(init?.headers).get("authorization")); + if (tokens.length > 1) return Response.json(payload); + await writeFile( + path, + credentials(Date.now() + 3_600_000).replace("token-123", "token-456"), + ); + throw new Error("network down"); + }, + 60_000, + path, + 1, + ); + await tracker.init(); + expect(tokens).toEqual(["Bearer token-123", "Bearer token-456"]); + expect(tracker.status()?.plan).toBe("max"); + expect(changed).toBe(1); + await tracker.dispose(); +}); + +test("corrupt credentials file keeps previous status and retries", async () => { + await writeFile(path, credentials(Date.now() + 3_600_000)); + let calls = 0; + let changed = 0; + const tracker = new ClaudeQuotaTracker( + async () => { + changed++; + }, + async () => { + calls++; + return Response.json(payload); + }, + 60_000, + path, + 1, + ); + await tracker.init(); + await writeFile(path, "{not json"); + await tracker.refresh(); + await writeFile(path, JSON.stringify({ claudeAiOauth: { accessToken: 1 } })); + await tracker.refresh(); + expect(calls).toBe(1); + expect(changed).toBe(1); + expect(tracker.status()?.windows).toHaveLength(4); + await tracker.dispose(); +}); + +test("missing credentials file clears status without retry", async () => { + await writeFile(path, credentials(Date.now() + 3_600_000)); + let calls = 0; + let changed = 0; + const tracker = new ClaudeQuotaTracker( + async () => { + changed++; + }, + async () => { + calls++; + return Response.json(payload); + }, + 60_000, + path, + 10_000, + ); + await tracker.init(); + await rm(path); + const start = performance.now(); + await tracker.refresh(); + expect(performance.now() - start).toBeLessThan(1_000); + expect(calls).toBe(1); + expect(changed).toBe(2); + expect(tracker.status()).toBeUndefined(); + await tracker.dispose(); +}); + +test("gives up after five failed attempts", async () => { + await writeFile(path, credentials(Date.now() + 3_600_000)); + let calls = 0; + const tracker = new ClaudeQuotaTracker( + async () => {}, + async () => { + calls++; + return new Response("nope", { status: 500 }); + }, + 60_000, + path, + 1, + ); + await tracker.init(); + expect(calls).toBe(5); + expect(tracker.status()).toBeUndefined(); + await tracker.refresh(); + expect(calls).toBe(10); + await tracker.dispose(); +}); + +test("dispose during backoff resolves quickly", async () => { + await writeFile(path, credentials(Date.now() + 3_600_000)); + let calls = 0; + let changed = 0; + const tracker = new ClaudeQuotaTracker( + async () => { + changed++; + }, + async () => { + calls++; + throw new Error("network down"); + }, + 60_000, + path, + 60_000, + ); + const init = tracker.init(); + while (calls === 0) await new Promise((resolve) => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 5)); + const start = performance.now(); + await tracker.dispose(); + await init; + expect(performance.now() - start).toBeLessThan(1_000); + expect(calls).toBe(1); + expect(changed).toBe(0); + expect(tracker.status()).toBeUndefined(); +});