diff --git a/src/render/lines/usage.ts b/src/render/lines/usage.ts index b8f2d48..a990788 100644 --- a/src/render/lines/usage.ts +++ b/src/render/lines/usage.ts @@ -78,9 +78,8 @@ function formatUsagePercent(percent: number | null): string { function formatUsageError(error?: string): string { if (!error) return ''; - if (error.startsWith('http-')) { - return ` (${error.slice(5)})`; - } + if (error === 'rate-limited') return ' (syncing...)'; + if (error.startsWith('http-')) return ` (${error.slice(5)})`; return ` (${error})`; } diff --git a/src/render/session-line.ts b/src/render/session-line.ts index 9cce7b0..caafa68 100644 --- a/src/render/session-line.ts +++ b/src/render/session-line.ts @@ -251,9 +251,8 @@ function formatUsagePercent(percent: number | null): string { function formatUsageError(error?: string): string { if (!error) return ''; - if (error.startsWith('http-')) { - return ` (${error.slice(5)})`; - } + if (error === 'rate-limited') return ' (syncing...)'; + if (error.startsWith('http-')) return ` (${error.slice(5)})`; return ` (${error})`; } diff --git a/src/usage-api.ts b/src/usage-api.ts index 57dd42a..e4e3a55 100644 --- a/src/usage-api.ts +++ b/src/usage-api.ts @@ -40,11 +40,15 @@ interface UsageApiResponse { interface UsageApiResult { data: UsageApiResponse | null; error?: string; + /** Retry-After header value in seconds (from 429 responses) */ + retryAfterSec?: number; } // File-based cache (HUD runs as new process each render, so in-memory cache won't persist) -const CACHE_TTL_MS = 60_000; // 60 seconds +const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests +const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff +const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff const CACHE_LOCK_STALE_MS = 30_000; const CACHE_LOCK_WAIT_MS = 2_000; const CACHE_LOCK_POLL_MS = 50; @@ -75,6 +79,12 @@ function isUsingCustomApiEndpoint(env: NodeJS.ProcessEnv = process.env): boolean interface CacheFile { data: UsageData; timestamp: number; + /** Consecutive 429 count for exponential backoff */ + rateLimitedCount?: number; + /** Absolute timestamp (ms) when retry is allowed (from Retry-After header) */ + retryAfterUntil?: number; + /** Last successful API data — preserved across rate-limited periods */ + lastGoodData?: UsageData; } interface CacheState { @@ -107,6 +117,27 @@ function hydrateCacheData(data: UsageData): UsageData { type CacheTtls = { cacheTtlMs: number; failureCacheTtlMs: number }; +function getRateLimitedTtlMs(count: number): number { + // Exponential backoff: 60s, 120s, 240s, capped at 5 min + return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS); +} + +function getRateLimitedRetryUntil(cache: CacheFile): number | null { + if (cache.data.apiError !== 'rate-limited') { + return null; + } + + if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) { + return cache.retryAfterUntil; + } + + if (cache.rateLimitedCount && cache.rateLimitedCount > 0) { + return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount); + } + + return null; +} + function readCacheState(homeDir: string, now: number, ttls: CacheTtls): CacheState | null { try { const cachePath = getCachePath(homeDir); @@ -115,10 +146,20 @@ function readCacheState(homeDir: string, now: number, ttls: CacheTtls): CacheSta const content = fs.readFileSync(cachePath, 'utf8'); const cache: CacheFile = JSON.parse(content); - // Check TTL - use shorter TTL for failure results + // Only serve lastGoodData during rate-limit backoff. Other failures should remain visible. + const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData) + ? cache.lastGoodData + : cache.data; + + const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache); + if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) { + return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true }; + } + const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs; + return { - data: hydrateCacheData(cache.data), + data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: now - cache.timestamp < ttl, }; @@ -127,12 +168,42 @@ function readCacheState(homeDir: string, now: number, ttls: CacheTtls): CacheSta } } +function readRateLimitedCount(homeDir: string): number { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) return 0; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: CacheFile = JSON.parse(content); + return cache.rateLimitedCount ?? 0; + } catch { + return 0; + } +} + +function readLastGoodData(homeDir: string): UsageData | null { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) return null; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: CacheFile = JSON.parse(content); + return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null; + } catch { + return null; + } +} + function readCache(homeDir: string, now: number, ttls: CacheTtls): UsageData | null { const cache = readCacheState(homeDir, now, ttls); return cache?.isFresh ? cache.data : null; } -function writeCache(homeDir: string, data: UsageData, timestamp: number): void { +interface WriteCacheOpts { + rateLimitedCount?: number; + retryAfterUntil?: number; + lastGoodData?: UsageData; +} + +function writeCache(homeDir: string, data: UsageData, timestamp: number, opts?: WriteCacheOpts): void { try { const cachePath = getCachePath(homeDir); const cacheDir = path.dirname(cachePath); @@ -142,6 +213,15 @@ function writeCache(homeDir: string, data: UsageData, timestamp: number): void { } const cache: CacheFile = { data, timestamp }; + if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) { + cache.rateLimitedCount = opts.rateLimitedCount; + } + if (opts?.retryAfterUntil) { + cache.retryAfterUntil = opts.retryAfterUntil; + } + if (opts?.lastGoodData) { + cache.lastGoodData = opts.lastGoodData; + } fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); } catch { // Ignore cache write failures @@ -323,7 +403,17 @@ export async function getUsage(overrides: Partial = {}): Promise = {}): Promise = {}): Promise { res.on('end', () => { if (res.statusCode !== 200) { debug('API returned non-200 status:', res.statusCode); - resolve({ data: null, error: res.statusCode ? `http-${res.statusCode}` : 'http-error' }); + // Use a distinct error key for 429 so cache/render can handle it specially + const error = res.statusCode === 429 + ? 'rate-limited' + : res.statusCode ? `http-${res.statusCode}` : 'http-error'; + // Parse Retry-After header (seconds) from 429 responses + let retryAfterSec: number | undefined; + if (res.statusCode === 429) { + const raw = res.headers['retry-after']; + if (raw) { + const parsed = parseInt(String(raw), 10); + if (Number.isFinite(parsed) && parsed > 0) { + retryAfterSec = parsed; + debug('Retry-After:', retryAfterSec, 'seconds'); + } + } + } + resolve({ data: null, error, retryAfterSec }); return; } diff --git a/tests/render.test.js b/tests/render.test.js index 545e387..b8856ce 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -717,6 +717,23 @@ test('renderSessionLine displays warning when API is unavailable', () => { assert.ok(!line.includes('5h:'), 'should not show 5h when API unavailable'); }); +test('renderSessionLine shows syncing hint when usage API is rate-limited', () => { + const ctx = baseContext(); + ctx.usageData = { + planName: 'Max', + fiveHour: null, + sevenDay: null, + fiveHourResetAt: null, + sevenDayResetAt: null, + apiUnavailable: true, + apiError: 'rate-limited', + }; + const line = renderSessionLine(ctx); + assert.ok(line.includes('usage:'), 'should show usage label'); + assert.ok(line.includes('syncing...'), 'should show syncing hint for rate limiting'); + assert.ok(!line.includes('rate-limited'), 'should not expose raw rate-limit error key'); +}); + test('renderSessionLine hides usage when showUsage config is false (hybrid toggle)', () => { const ctx = baseContext(); ctx.usageData = { diff --git a/tests/usage-api.test.js b/tests/usage-api.test.js index 0347eb2..ce1d0be 100644 --- a/tests/usage-api.test.js +++ b/tests/usage-api.test.js @@ -818,7 +818,7 @@ describe('getKeychainServiceNames', () => { }); }); -describe('getUsage caching behavior', () => { +describe('getUsage caching behavior', { concurrency: false }, () => { beforeEach(async () => { cacheTempHome = await createTempHome(); clearCache(cacheTempHome); @@ -831,7 +831,7 @@ describe('getUsage caching behavior', () => { } }); - test('cache expires after 60 seconds for success', async () => { + test('cache expires after 5 minutes for success', async () => { await writeCredentials(cacheTempHome, buildCredentials()); let fetchCalls = 0; let nowValue = 1000; @@ -843,11 +843,13 @@ describe('getUsage caching behavior', () => { await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null }); assert.equal(fetchCalls, 1); - nowValue += 30_000; + // Still fresh at 2 minutes + nowValue += 120_000; await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null }); assert.equal(fetchCalls, 1); - nowValue += 31_000; + // Expired after 5 minutes + nowValue += 181_000; await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null }); assert.equal(fetchCalls, 2); }); @@ -911,6 +913,97 @@ describe('getUsage caching behavior', () => { assert.equal(fetchCalls, 2); }); + test('serves last good data during rate-limit backoff only', async () => { + await writeCredentials(cacheTempHome, buildCredentials()); + + let nowValue = 1000; + let fetchCalls = 0; + const fetchApi = async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return buildApiResult({ + data: buildApiResponse({ + five_hour: { + utilization: 25, + resets_at: '2026-01-06T15:00:00Z', + }, + }), + }); + } + return { data: null, error: 'rate-limited', retryAfterSec: 120 }; + }; + + const initial = await getUsage({ + homeDir: () => cacheTempHome, + fetchApi, + now: () => nowValue, + readKeychain: () => null, + }); + assert.equal(initial?.fiveHour, 25); + + nowValue += 301_000; + const rateLimited = await getUsage({ + homeDir: () => cacheTempHome, + fetchApi, + now: () => nowValue, + readKeychain: () => null, + }); + assert.equal(rateLimited?.fiveHour, 25); + assert.equal(fetchCalls, 2); + + nowValue += 60_000; + const cachedDuringBackoff = await getUsage({ + homeDir: () => cacheTempHome, + fetchApi, + now: () => nowValue, + readKeychain: () => null, + }); + assert.equal(cachedDuringBackoff?.fiveHour, 25); + assert.equal(fetchCalls, 2); + }); + + test('does not mask non-rate-limited failures with stale good data', async () => { + await writeCredentials(cacheTempHome, buildCredentials()); + + let nowValue = 1000; + let fetchCalls = 0; + const fetchApi = async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return buildApiResult({ + data: buildApiResponse({ + five_hour: { + utilization: 25, + resets_at: '2026-01-06T15:00:00Z', + }, + }), + }); + } + return { data: null, error: 'network' }; + }; + + const initial = await getUsage({ + homeDir: () => cacheTempHome, + fetchApi, + now: () => nowValue, + readKeychain: () => null, + }); + assert.equal(initial?.fiveHour, 25); + + nowValue += 301_000; + const failure = await getUsage({ + homeDir: () => cacheTempHome, + fetchApi, + now: () => nowValue, + readKeychain: () => null, + }); + + assert.equal(fetchCalls, 2); + assert.equal(failure?.apiUnavailable, true); + assert.equal(failure?.apiError, 'network'); + assert.equal(failure?.fiveHour, null); + }); + test('deduplicates concurrent refreshes when cache is missing', async () => { await writeCredentials(cacheTempHome, buildCredentials()); @@ -962,7 +1055,7 @@ describe('getUsage caching behavior', () => { readKeychain: () => null, }); - nowValue += 61_000; + nowValue += 301_000; let fetchCalls = 0; let releaseFetch = () => {};