fix: resilient usage display under API rate limiting (#193)

* fix: resilient usage display under API rate limiting

The Anthropic usage API rate-limits to ~1 call per 5 minutes. With the
previous 60s cache TTL, 4 out of 5 API calls returned 429, causing the
HUD to permanently display "(429)" instead of actual usage data.

Three-layer fix:
- Increase cache TTL from 60s to 5 minutes to match rate limit window
- Preserve lastGoodData in cache across rate-limited periods so the HUD
  always shows the best available data instead of errors
- Exponential backoff (60s→120s→240s→5min cap) with Retry-After header
  support for consecutive 429 responses

Also show "syncing..." instead of raw HTTP status on first-run rate limit.

* Update usage-api.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden 429 cache fallback behavior

* test: stabilize usage cache suite after rebase

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jarrod Watts <jarrod@cubelabs.xyz>
This commit is contained in:
Paul
2026-03-13 17:11:36 -07:00
committed by GitHub
parent 98cde23e48
commit e8d64924bc
5 changed files with 249 additions and 20 deletions

View File

@@ -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 = {

View File

@@ -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 = () => {};