mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-21 15:52:37 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
Reference in New Issue
Block a user