Skip to content

Commit 08d3c7c

Browse files
🐛 fix(usage): clear in-flight lock on a successful fetch (#487)
* fix(usage): drop in-flight lock on fetch success The pre-fetch usage.lock is written as a 'timeout' guard before the API call but is never removed on success, so it lingers for LOCK_MAX_AGE. A cache miss in that window (e.g. an account switch invalidating the token fingerprint) then returns getStaleUsageOrError('timeout', ...), showing a spurious [Timeout] while the API is healthy. Clear the lock after a successful cache write. Genuine error and rate-limit backoff locks are untouched. Fixes #486 * fix(usage): preserve throttle for incomplete usage fetches Only clear the in-flight usage lock after a successful API response satisfies the fields requested by the caller. Aggregate-only responses that are missing per-model fields remain cached, but keep the short timeout lock so later renders do not refetch the API on every status line render. Add regression coverage for a weekly Sonnet usage request receiving an aggregate-only 200 response, verifying that the second fetch is throttled instead of issuing another API request. --------- Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
1 parent 6793c75 commit 08d3c7c

2 files changed

Lines changed: 96 additions & 3 deletions

File tree

src/utils/__tests__/usage-fetch.test.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -674,11 +674,17 @@ describe('fetchUsageData error handling', () => {
674674
expect(result.second).toEqual(result.first);
675675
expect(result.requestCount).toBe(1);
676676

677+
// The probe writes usage.json with a real-wall-clock mtime, so derive
678+
// 'now' from it (not the mocked epoch) to keep the cache within
679+
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
680+
// render takes, rather than depending on a lingering lock to suppress
681+
// the refetch.
682+
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
677683
const cachedResult = harness.runProbe({
678684
claudeConfigDir: home.claudeConfig,
679685
home: home.home,
680686
mode: 'unexpected',
681-
nowMs: nowMs + 10000,
687+
nowMs: cacheMtimeMs + 10000,
682688
pathDir: home.bin,
683689
requiredFields
684690
});
@@ -691,6 +697,66 @@ describe('fetchUsageData error handling', () => {
691697
}
692698
});
693699

700+
it('clears the in-flight lock after a successful fetch', () => {
701+
const harness = createProbeHarness();
702+
703+
try {
704+
const home = harness.createTokenHome('success-clears-lock');
705+
const result = harness.runProbe({
706+
claudeConfigDir: home.claudeConfig,
707+
home: home.home,
708+
mode: 'success',
709+
nowMs,
710+
pathDir: home.bin,
711+
responseBody: successResponseBody
712+
});
713+
714+
// fetchUsageData writes a short 'timeout' lock before the request as an
715+
// in-flight guard. A successful fetch must remove it; otherwise the lock
716+
// lingers for LOCK_MAX_AGE and a later cache miss (e.g. an account switch
717+
// invalidating the fingerprint) reports a spurious [Timeout] while the
718+
// API is healthy.
719+
expect(result.cacheExists).toBe(true);
720+
expect(result.lockExists).toBe(false);
721+
expect(result.lockContents).toBeNull();
722+
} finally {
723+
harness.cleanup();
724+
}
725+
});
726+
727+
it('preserves the in-flight lock after a successful fetch missing required fields', () => {
728+
const harness = createProbeHarness();
729+
730+
try {
731+
const home = harness.createTokenHome('success-missing-required-fields');
732+
const result = harness.runProbe({
733+
claudeConfigDir: home.claudeConfig,
734+
home: home.home,
735+
mode: 'success',
736+
nowMs,
737+
pathDir: home.bin,
738+
requiredFields: ['weeklySonnetUsage'],
739+
responseBody: successResponseBody
740+
});
741+
742+
expect(result.first).toEqual({
743+
sessionUsage: 42,
744+
sessionResetAt: '2030-01-01T00:00:00.000Z',
745+
weeklyUsage: 17,
746+
weeklyResetAt: '2030-01-07T00:00:00.000Z'
747+
});
748+
expect(result.second).toEqual({ error: 'timeout' });
749+
expect(result.cacheExists).toBe(true);
750+
expect(result.requestCount).toBe(1);
751+
expect(parseLockContents(result.lockContents)).toEqual({
752+
blockedUntil: Math.floor(nowMs / 1000) + 30,
753+
error: 'timeout'
754+
});
755+
} finally {
756+
harness.cleanup();
757+
}
758+
});
759+
694760
it('refetches a fresh cache when the token fingerprint changes (account switch)', () => {
695761
const harness = createProbeHarness();
696762

@@ -849,11 +915,17 @@ describe('fetchUsageData error handling', () => {
849915
expect(result.second).toEqual(result.first);
850916
expect(result.requestCount).toBe(1);
851917

918+
// The probe writes usage.json with a real-wall-clock mtime, so derive
919+
// 'now' from it (not the mocked epoch) to keep the cache within
920+
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
921+
// render takes, rather than depending on a lingering lock to suppress
922+
// the refetch.
923+
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
852924
const cachedResult = harness.runProbe({
853925
claudeConfigDir: home.claudeConfig,
854926
home: home.home,
855927
mode: 'unexpected',
856-
nowMs: nowMs + 10000,
928+
nowMs: cacheMtimeMs + 10000,
857929
pathDir: home.bin,
858930
requiredFields
859931
});
@@ -895,11 +967,17 @@ describe('fetchUsageData error handling', () => {
895967
expect(result.second).toEqual(result.first);
896968
expect(result.requestCount).toBe(1);
897969

970+
// The probe writes usage.json with a real-wall-clock mtime, so derive
971+
// 'now' from it (not the mocked epoch) to keep the cache within
972+
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
973+
// render takes, rather than depending on a lingering lock to suppress
974+
// the refetch.
975+
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
898976
const cachedResult = harness.runProbe({
899977
claudeConfigDir: home.claudeConfig,
900978
home: home.home,
901979
mode: 'unexpected',
902-
nowMs: nowMs + 10000,
980+
nowMs: cacheMtimeMs + 10000,
903981
pathDir: home.bin,
904982
requiredFields
905983
});

src/utils/usage-fetch.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,14 @@ function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
428428
}
429429
}
430430

431+
function clearUsageLock(): void {
432+
try {
433+
fs.rmSync(LOCK_FILE, { force: true });
434+
} catch {
435+
// Ignore lock file errors
436+
}
437+
}
438+
431439
function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null {
432440
let hasValidJsonLock = false;
433441

@@ -664,6 +672,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
664672
// Ignore cache write errors
665673
}
666674

675+
// Clear the in-flight lock written above only once this response satisfies
676+
// the caller's requested fields. Incomplete 200 responses are cached but
677+
// still need the short throttle so later renders do not refetch every time.
678+
if (hasRequiredUsageFields(usageData, requiredFields)) {
679+
clearUsageLock();
680+
}
681+
667682
return cacheUsageData(usageData, now);
668683
} catch {
669684
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');

0 commit comments

Comments
 (0)