Compare commits

..
2 Commits
Author SHA1 Message Date
StanPonomarev 5edb1b5449 Merge pull request 'fix: claude limits missing sometimes' (#21) from fix/claude-limits-retries into main
Reviewed-on: #21
2026-10-08 07:13:46 +02:00
StanPonomarev a3c5ae5c42 fix: claude limits missing sometimes
CI / verify (pull_request) Successful in 11s
2026-10-08 07:12:33 +02:00
2 changed files with 176 additions and 15 deletions
+45 -16
View File
@@ -8,6 +8,8 @@ import type { QuotaSnapshot, QuotaStatus, QuotaWindow } from "./quota.js";
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const POLL_MS = 60_000; const POLL_MS = 60_000;
const TIMEOUT_MS = 5_000; const TIMEOUT_MS = 5_000;
const RETRY_MS = 2_000;
const RETRIES = 4;
const WEEK = 604_800; const WEEK = 604_800;
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>; type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -108,6 +110,7 @@ export class ClaudeQuotaTracker {
private timer: ReturnType<typeof setInterval> | undefined; private timer: ReturnType<typeof setInterval> | undefined;
private request: Promise<void> | undefined; private request: Promise<void> | undefined;
private controller: AbortController | undefined; private controller: AbortController | undefined;
private wake: (() => void) | undefined;
private disposed = false; private disposed = false;
constructor( constructor(
@@ -115,6 +118,7 @@ export class ClaudeQuotaTracker {
private readonly fetcher: QuotaFetch = fetch, private readonly fetcher: QuotaFetch = fetch,
private readonly pollMs = POLL_MS, private readonly pollMs = POLL_MS,
private readonly path = claudeCredentialsPath(), private readonly path = claudeCredentialsPath(),
private readonly retryMs = RETRY_MS,
) {} ) {}
async init() { async init() {
@@ -131,22 +135,44 @@ export class ClaudeQuotaTracker {
} }
private async query() { private async query() {
try { for (let attempt = 0; ; attempt++) {
let credential: z.infer<typeof credentials>["claudeAiOauth"] | undefined; if (await this.attempt()) return;
try { if (attempt >= RETRIES || this.disposed) return;
const parsed = credentials.safeParse(JSON.parse(await readFile(this.path, "utf8"))); await this.sleep(this.retryMs * 2 ** attempt);
credential = parsed.success ? parsed.data.claudeAiOauth : undefined;
} catch {
credential = undefined;
} }
if (!credential) { }
if (this.latest) {
private sleep(ms: number) {
return new Promise<void>((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 text: string;
try {
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; 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(); const controller = new AbortController();
this.controller = controller; this.controller = controller;
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -164,19 +190,21 @@ export class ClaudeQuotaTracker {
clearTimeout(timeout); clearTimeout(timeout);
if (this.controller === controller) this.controller = undefined; 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. // SAFETY: Response.json yields JSON values; usagePayload validates fields used.
const payload = (await response.json()) as Schema.Json; const payload = (await response.json()) as Schema.Json;
const snapshot = normalizeClaudeQuota( const snapshot = normalizeClaudeQuota(
payload, payload,
credential.subscriptionType ?? "unknown", credential.subscriptionType ?? "unknown",
); );
if (!snapshot || this.disposed) return; if (this.disposed) return true;
if (!snapshot) return false;
this.latest = snapshot; this.latest = snapshot;
await this.changed();
} catch { } catch {
return; return false;
} }
await this.changed().catch(() => {});
return true;
} }
status(): QuotaStatus | undefined { status(): QuotaStatus | undefined {
@@ -193,6 +221,7 @@ export class ClaudeQuotaTracker {
async dispose() { async dispose() {
this.disposed = true; this.disposed = true;
this.controller?.abort(); this.controller?.abort();
this.wake?.();
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = undefined; this.timer = undefined;
await this.request; await this.request;
+132
View File
@@ -148,6 +148,7 @@ test("expired token skips fetch and keeps last snapshot", async () => {
}, },
60_000, 60_000,
path, path,
1,
); );
await tracker.init(); await tracker.init();
await writeFile(path, credentials(Date.now() - 1)); 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); expect(tracker.status()?.windows).toHaveLength(4);
await tracker.dispose(); await tracker.dispose();
}); });
test("failed fetch retries with re-read credentials", async () => {
await writeFile(path, credentials(Date.now() + 3_600_000));
const tokens: Array<string | null> = [];
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();
});