diff --git a/README.md b/README.md index 7390a4b..04d5a27 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Shows the current usage of Claude Code in a CloudCLI tab: - **Live plan limits** — session (5h window), weekly, and per-model utilization gauges with severity coloring and reset countdowns, straight from the same - endpoint that powers Claude Code's `/usage` screen. Auto-refreshes every 60s. + endpoint that powers Claude Code's `/usage` screen. Auto-refreshes every + 5 minutes (the endpoint is strictly rate-limited, see below). - **30-day history** — daily token bar chart, totals (tokens / estimated cost / sessions), and per-model / per-project breakdowns, parsed locally from `~/.claude/projects/**/*.jsonl` transcripts. @@ -45,7 +46,7 @@ The backend subprocess (`dist/server.js`) exposes two endpoints via the host's RPC proxy: - `GET /live` — reads the OAuth token from `~/.claude/.credentials.json` and - calls `https://api.anthropic.com/api/oauth/usage` (cached 30s). It never + calls `https://api.anthropic.com/api/oauth/usage` (cached 5 minutes). It never writes to the credentials file and never uses the refresh token; if the token is expired it asks you to run `claude` to refresh it. - `GET /history?days=30` — parses local session transcripts (deduped by message @@ -58,6 +59,20 @@ vice-versa. > **Note:** the usage endpoint is unofficial and may change; the live section > degrades gracefully if it does. +### Rate limiting + +The usage endpoint allows only a handful of requests per window, and that +budget is shared with Claude Code's own `/usage` screen and statusline (see +[anthropics/claude-code#30930](https://github.com/anthropics/claude-code/issues/30930)). +To stay well under it, the plugin: + +- calls the endpoint at most once every 5 minutes, no matter how many tabs are + open or how often you click refresh (concurrent requests share one call); +- on HTTP 429 honors `Retry-After` when present, otherwise backs off + 5 → 10 → 20 → 40 → 60 minutes between attempts; +- keeps showing the last good reading (marked as stale, with the reason) while + it waits, instead of blanking the gauges. + ## Development ```bash diff --git a/src/index.ts b/src/index.ts index a215764..fe4226c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,11 @@ import type { PluginAPI, PluginContext } from './types.js'; interface TokenCounts { input: number; output: number; cacheCreate: number; cacheRead: number } interface LiveLimit { kind: string; label: string; percent: number; severity: string; resetsAt: string | null; model: string | null } -interface LiveData { plan: string | null; tier: string | null; limits: LiveLimit[] } +interface LiveData { + plan: string | null; tier: string | null; limits: LiveLimit[]; + fetchedAt: number; stale: boolean; error: string | null; retryAt: number | null; +} + interface HistoryData { daily: { date: string; tokens: TokenCounts; cost: number }[]; byModel: { model: string; tokens: TokenCounts; cost: number | null }[]; @@ -17,6 +21,12 @@ interface HistoryData { totals: { tokens: TokenCounts; cost: number; sessions: number; messages: number }; } +// The usage endpoint rate-limits after a handful of calls and the limit is shared +// with Claude Code's own /usage screen, so poll it rarely. The server caches for +// the same interval and backs off on 429, so the refresh button is safe too. +const LIVE_POLL_MS = 5 * 60_000; +const TICK_MS = 60_000; + // ── Theme (matches starter conventions) ───────────────────────────────── const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace"; @@ -63,6 +73,13 @@ function resetsIn(iso: string | null): string { return h > 0 ? `resets in ${h}h ${m}m` : `resets in ${m}m`; } +function ago(ms: number): string { + const m = Math.floor(ms / 60_000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + return `${Math.floor(m / 60)}h ${m % 60}m ago`; +} + function esc(s: string): string { return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch] as string)); } @@ -85,13 +102,22 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null) const badge = live?.plan ? `${esc(live.plan)}${live.tier ? ` · ${esc(live.tier.replace(/^default_claude_/, ''))}` : ''}` : ''; + const updated = live + ? `updated ${ago(Date.now() - live.fetchedAt)}` + : ''; let body: string; if (error) { body = errorNote(c, error); } else if (!live || live.limits.length === 0) { body = `
No limit data available.
`; } else { - body = live.limits.map((l) => { + if (live.stale && live.error) { + body = errorNote(c, `${live.error} Showing last reading from ${ago(Date.now() - live.fetchedAt)}.`) + + `
`; + } else { + body = ''; + } + body += live.limits.map((l) => { const color = sevColor(c, l.severity, l.percent); const pct = Math.max(0, Math.min(100, l.percent)); return ` @@ -106,7 +132,7 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null) `; }).join(''); } - return card(c, `plan limits ${badge}`, body); + return card(c, `plan limits ${badge}${updated}`, body); } function renderChart(c: ThemeColors, daily: HistoryData['daily']): string { @@ -227,12 +253,15 @@ export function mount(container: HTMLElement, api: PluginAPI): void { render(api.context); void load(true); - // Live gauges poll every 60s while mounted; countdown text refreshes with them. - const timer = window.setInterval(() => void load(false), 60_000); + // Live gauges re-fetch every 5 minutes; countdown / "updated" text re-renders + // every minute without touching the network. + const poll = window.setInterval(() => void load(false), LIVE_POLL_MS); + const tick = window.setInterval(() => render(api.context), TICK_MS); const unsubscribe = api.onContextChange((ctx) => render(ctx)); (container as any)._cuCleanup = () => { - window.clearInterval(timer); + window.clearInterval(poll); + window.clearInterval(tick); unsubscribe(); }; } diff --git a/src/live-cache.test.ts b/src/live-cache.test.ts new file mode 100644 index 0000000..0faf086 --- /dev/null +++ b/src/live-cache.test.ts @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LiveCache, LIVE_CACHE_MS, parseRetryAfter, backoffMs, UsageResponse } from './live.js'; + +const BODY = { limits: [{ kind: 'session', percent: 10, severity: 'normal', resets_at: null }] }; + +function harness(responses: (UsageResponse | Error)[]) { + let now = 1_000_000; + let calls = 0; + const cache = new LiveCache( + async () => { + const r = responses[Math.min(calls, responses.length - 1)]; + calls++; + if (r instanceof Error) throw r; + return r; + }, + () => now, + ); + return { cache, calls: () => calls, advance: (ms: number) => { now += ms; }, now: () => now }; +} + +const ok: UsageResponse = { status: 200, retryAfter: null, body: BODY, plan: 'max', tier: null }; +const limited: UsageResponse = { status: 429, retryAfter: null, body: null, plan: 'max', tier: null }; + +test('first call fetches and reports when it was fetched', async () => { + const h = harness([ok]); + const r = await h.cache.get(); + assert.equal(h.calls(), 1); + assert.equal(r.limits[0].percent, 10); + assert.equal(r.fetchedAt, h.now()); + assert.equal(r.stale, false); + assert.equal(r.error, null); +}); + +test('calls within the cache window do not hit the endpoint', async () => { + const h = harness([ok]); + await h.cache.get(); + h.advance(LIVE_CACHE_MS - 1); + await h.cache.get(); + assert.equal(h.calls(), 1); + h.advance(1); + await h.cache.get(); + assert.equal(h.calls(), 2); +}); + +test('cache window is at least five minutes', () => { + assert.ok(LIVE_CACHE_MS >= 5 * 60_000); +}); + +test('concurrent calls share one in-flight fetch', async () => { + const h = harness([ok]); + await Promise.all([h.cache.get(), h.cache.get(), h.cache.get()]); + assert.equal(h.calls(), 1); +}); + +test('429 with cached data serves the stale reading and a retry time', async () => { + const h = harness([ok, limited]); + await h.cache.get(); + h.advance(LIVE_CACHE_MS); + const r = await h.cache.get(); + assert.equal(h.calls(), 2); + assert.equal(r.stale, true); + assert.equal(r.limits[0].percent, 10); + assert.match(r.error!, /429/); + assert.equal(r.retryAt, h.now() + backoffMs(1)); +}); + +test('no fetch happens until the retry time has passed', async () => { + const h = harness([ok, limited, ok]); + await h.cache.get(); + h.advance(LIVE_CACHE_MS); + const r = await h.cache.get(); + h.advance(r.retryAt! - h.now() - 1); + await h.cache.get(); + assert.equal(h.calls(), 2); + h.advance(1); + const fresh = await h.cache.get(); + assert.equal(h.calls(), 3); + assert.equal(fresh.stale, false); + assert.equal(fresh.error, null); +}); + +test('429 honors the Retry-After header', async () => { + const h = harness([{ ...limited, retryAfter: '900' }]); + await assert.rejects(h.cache.get()); + h.advance(899_000); + await assert.rejects(h.cache.get(), /429/); + assert.equal(h.calls(), 1); +}); + +test('repeated 429s back off exponentially up to a cap', async () => { + assert.equal(backoffMs(1), 5 * 60_000); + assert.equal(backoffMs(2), 10 * 60_000); + assert.equal(backoffMs(3), 20 * 60_000); + assert.equal(backoffMs(4), 40 * 60_000); + assert.equal(backoffMs(5), 60 * 60_000); + assert.equal(backoffMs(9), 60 * 60_000); +}); + +test('consecutive 429s lengthen the wait; a success resets it', async () => { + const h = harness([limited, limited, ok, limited]); + await assert.rejects(h.cache.get()); + h.advance(backoffMs(1)); + await assert.rejects(h.cache.get()); + h.advance(backoffMs(2)); + await h.cache.get(); + h.advance(LIVE_CACHE_MS); + const r = await h.cache.get(); + assert.equal(r.retryAt, h.now() + backoffMs(1)); +}); + +test('429 without cached data throws a 429 with the wait in the message', async () => { + const h = harness([limited]); + await assert.rejects(h.cache.get(), (err: any) => err.status === 429 && /5 min/.test(err.message)); +}); + +test('a thrown fetch error keeps stale data and retries at the normal interval', async () => { + const h = harness([ok, new Error('boom'), ok]); + await h.cache.get(); + h.advance(LIVE_CACHE_MS); + const r = await h.cache.get(); + assert.equal(r.stale, true); + assert.equal(r.error, 'boom'); + h.advance(LIVE_CACHE_MS); + const fresh = await h.cache.get(); + assert.equal(fresh.stale, false); + assert.equal(h.calls(), 3); +}); + +test('parseRetryAfter reads seconds and HTTP dates, ignores junk', () => { + assert.equal(parseRetryAfter('120', 0), 120_000); + assert.equal(parseRetryAfter('0', 0), null); + assert.equal(parseRetryAfter('garbage', 0), null); + assert.equal(parseRetryAfter(null, 0), null); + const now = Date.parse('2026-10-30T08:00:00Z'); + assert.equal(parseRetryAfter('Fri, 30 Oct 2026 08:10:00 GMT', now), 600_000); + assert.equal(parseRetryAfter('Fri, 30 Oct 2026 07:00:00 GMT', now), null); +}); + +test('after a non-429 error, calls before the next interval do not re-fetch', async () => { + const h = harness([ok, new Error('boom')]); + await h.cache.get(); + h.advance(LIVE_CACHE_MS); + await h.cache.get(); + h.advance(LIVE_CACHE_MS - 1); + await h.cache.get(); + assert.equal(h.calls(), 2); +}); diff --git a/src/live.ts b/src/live.ts index 5bf68d5..c89f16d 100644 --- a/src/live.ts +++ b/src/live.ts @@ -60,3 +60,123 @@ export function normalizeUsage(raw: unknown, plan: string | null, tier: string | } return { plan, tier, limits }; } + +// ── Caching + rate-limit backoff ──────────────────────────────────────── +// +// The usage endpoint is rate-limited far more aggressively than normal API +// calls (a handful of requests per window, shared with Claude Code's own +// /usage screen). So: poll rarely, coalesce concurrent callers, and on 429 +// back off and keep showing the last good reading instead of hammering it. + +/** How long a successful reading is served without re-fetching. */ +export const LIVE_CACHE_MS = 5 * 60_000; +const BACKOFF_BASE_MS = 5 * 60_000; +const BACKOFF_CAP_MS = 60 * 60_000; + +/** What the server hands the cache after calling the endpoint. */ +export interface UsageResponse { + status: number; + retryAfter: string | null; + body: unknown; + plan: string | null; + tier: string | null; +} + +export interface LiveResult extends LiveData { + /** When the limits were last fetched successfully (ms epoch). */ + fetchedAt: number; + /** True when the reading could not be refreshed and is older than LIVE_CACHE_MS. */ + stale: boolean; + /** Why the last refresh failed, if it did. */ + error: string | null; + /** When the next fetch is allowed, if we are backing off. */ + retryAt: number | null; +} + +/** Delay before the n-th consecutive 429 is retried: 5m, 10m, 20m, 40m, 60m cap. */ +export function backoffMs(failures: number): number { + return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_CAP_MS); +} + +/** Retry-After header → delay in ms, or null when absent, zero, past, or unparsable. */ +export function parseRetryAfter(header: string | null, nowMs: number): number | null { + if (!header) return null; + const secs = Number(header); + if (Number.isFinite(secs)) return secs > 0 ? secs * 1000 : null; + const at = Date.parse(header); + return Number.isFinite(at) && at > nowMs ? at - nowMs : null; +} + +function fmtWait(ms: number): string { + const min = Math.ceil(ms / 60_000); + return min <= 1 ? '1 min' : `${min} min`; +} + +export class LiveCache { + private last: { at: number; data: LiveData } | null = null; + private failure: { message: string; status: number; retryAt: number | null } | null = null; + private failures429 = 0; + private nextAllowedAt = 0; + private inflight: Promise | null = null; + + constructor( + private readonly fetchUsage: () => Promise, + private readonly now: () => number = Date.now, + ) {} + + async get(): Promise { + if (this.inflight) { + await this.inflight; + } else if (this.now() >= this.nextAllowedAt) { + this.inflight = this.refresh().finally(() => { this.inflight = null; }); + await this.inflight; + } + return this.result(); + } + + private async refresh(): Promise { + const now = this.now(); + // Whatever happens, don't call the endpoint again before the normal cadence; + // a 429 pushes this further out below. + this.nextAllowedAt = now + LIVE_CACHE_MS; + let res: UsageResponse; + try { + res = await this.fetchUsage(); + } catch (err: any) { + this.failure = { message: err?.message ?? 'Usage fetch failed.', status: typeof err?.status === 'number' ? err.status : 502, retryAt: null }; + return; + } + if (res.status === 429) { + this.failures429++; + const wait = parseRetryAfter(res.retryAfter, now) ?? backoffMs(this.failures429); + this.failure = { + message: `Usage endpoint returned HTTP 429 (rate limited) — retrying in ${fmtWait(wait)}.`, + status: 429, + retryAt: now + wait, + }; + this.nextAllowedAt = now + wait; + return; + } + if (res.status < 200 || res.status >= 300) { + this.failure = { message: `Usage endpoint returned HTTP ${res.status}.`, status: 502, retryAt: null }; + return; + } + this.failures429 = 0; + this.failure = null; + this.last = { at: now, data: normalizeUsage(res.body, res.plan, res.tier) }; + } + + private result(): LiveResult { + if (!this.last) { + const f = this.failure!; + throw Object.assign(new Error(f.message), { status: f.status }); + } + return { + ...this.last.data, + fetchedAt: this.last.at, + stale: this.failure !== null, + error: this.failure?.message ?? null, + retryAt: this.failure?.retryAt ?? null, + }; + } +} diff --git a/src/server.ts b/src/server.ts index 02a323b..015eb89 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,20 +2,16 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { parseCredentials, normalizeUsage, LiveData } from './live.js'; +import { parseCredentials, LiveCache, LiveResult, Credentials } from './live.js'; import { parseTranscriptLine, projectLabel, aggregate, SessionEntries, UsageEntry } from './history.js'; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; -const LIVE_CACHE_MS = 30_000; // ── /live ────────────────────────────────────────────────────────────── -let liveCache: { at: number; data: LiveData } | null = null; - -async function getLive(): Promise { - if (liveCache && Date.now() - liveCache.at < LIVE_CACHE_MS) return liveCache.data; - +/** Local, no-network checks — never throttled, so a fresh login shows up immediately. */ +function readCredentials(): Credentials { let credsJson: unknown; try { credsJson = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, '.credentials.json'), 'utf-8')); @@ -27,7 +23,14 @@ async function getLive(): Promise { if (creds.expired) { throw Object.assign(new Error('OAuth token expired — run claude to refresh it, then retry.'), { status: 401 }); } + return creds; +} +// The endpoint is shared with Claude Code's own /usage screen and rate-limits +// after only a few calls, so LiveCache polls it at most every 5 minutes and +// backs off on 429 (see live.ts). +const liveCache = new LiveCache(async () => { + const creds = readCredentials(); const res = await fetch(USAGE_URL, { headers: { Authorization: `Bearer ${creds.accessToken}`, @@ -36,12 +39,18 @@ async function getLive(): Promise { }, signal: AbortSignal.timeout(10_000), }); - if (!res.ok) { - throw Object.assign(new Error(`Usage endpoint returned HTTP ${res.status}.`), { status: 502 }); - } - const data = normalizeUsage(await res.json(), creds.plan, creds.tier); - liveCache = { at: Date.now(), data }; - return data; + return { + status: res.status, + retryAfter: res.headers.get('retry-after'), + body: res.ok ? await res.json() : null, + plan: creds.plan, + tier: creds.tier, + }; +}); + +async function getLive(): Promise { + readCredentials(); + return liveCache.get(); } // ── /history ───────────────────────────────────────────────────────────