Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5edb1b5449 | ||
|
|
a3c5ae5c42 |
+44
-15
@@ -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() {
|
||||||
|
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<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 {
|
try {
|
||||||
let credential: z.infer<typeof credentials>["claudeAiOauth"] | undefined;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
const parsed = credentials.safeParse(JSON.parse(await readFile(this.path, "utf8")));
|
text = await readFile(this.path, "utf8");
|
||||||
credential = parsed.success ? parsed.data.claudeAiOauth : undefined;
|
} catch (error) {
|
||||||
} catch {
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
||||||
credential = undefined;
|
return false;
|
||||||
}
|
if (this.latest && !this.disposed) {
|
||||||
if (!credential) {
|
|
||||||
if (this.latest) {
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user