Skip to content

Commit 0bcc7eb

Browse files
elhoimclaude
andauthored
[low] fix(usage): only clear the in-flight lock this render wrote (#624)
* fix(usage): only clear the in-flight lock this render wrote A successful fetch removed usage.lock unconditionally. When two renders fetch concurrently and one receives a 429, the other's later success deleted the Retry-After lock, so the next render after the 180s cache expiry fetched again inside the server's backoff window. Clear the lock only while it still holds this render's own in-flight record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(usage): keep a rate-limit lock written during an in-flight fetch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 475e401 commit 0bcc7eb

2 files changed

Lines changed: 54 additions & 6 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface ProbeOptions {
4242
claudeConfigDir?: string;
4343
home: string;
4444
httpsProxy?: string;
45+
lockWrittenDuringRequest?: string;
4546
lowercaseHttpsProxy?: string;
4647
mode?: 'error' | 'status' | 'success' | 'unexpected';
4748
nowMs: number;
@@ -103,6 +104,12 @@ https.request = (...args) => {
103104
},
104105
destroy() {},
105106
end() {
107+
// Simulates a concurrent render replacing the lock while this
108+
// request is in flight.
109+
if (process.env.TEST_LOCK_DURING_REQUEST) {
110+
fs.writeFileSync(lockFile, process.env.TEST_LOCK_DURING_REQUEST);
111+
}
112+
106113
if (mode === 'error') {
107114
const handlers = requestHandlers.get('error') || [];
108115
for (const handler of handlers) {
@@ -226,6 +233,10 @@ process.stdout.write(JSON.stringify({
226233
env.HTTPS_PROXY = options.httpsProxy;
227234
}
228235

236+
if (options.lockWrittenDuringRequest !== undefined) {
237+
env.TEST_LOCK_DURING_REQUEST = options.lockWrittenDuringRequest;
238+
}
239+
229240
if (options.lowercaseHttpsProxy !== undefined) {
230241
env.https_proxy = options.lowercaseHttpsProxy;
231242
}
@@ -891,6 +902,35 @@ describe('fetchUsageData error handling', () => {
891902
}
892903
});
893904

905+
it('keeps a rate-limit lock that a concurrent render wrote during the fetch', () => {
906+
const harness = createProbeHarness();
907+
908+
try {
909+
const home = harness.createTokenHome('success-keeps-foreign-lock');
910+
// Another render got a 429 while this request was in flight. Deleting
911+
// its lock would let the next render fetch inside the Retry-After window.
912+
const rateLimitedLock = JSON.stringify({
913+
blockedUntil: Math.floor(nowMs / 1000) + 300,
914+
error: 'rate-limited'
915+
});
916+
const result = harness.runProbe({
917+
claudeConfigDir: home.claudeConfig,
918+
home: home.home,
919+
lockWrittenDuringRequest: rateLimitedLock,
920+
mode: 'success',
921+
nowMs,
922+
pathDir: home.bin,
923+
responseBody: successResponseBody
924+
});
925+
926+
expect(result.requestCount).toBe(1);
927+
expect(result.cacheExists).toBe(true);
928+
expect(result.lockContents).toBe(rateLimitedLock);
929+
} finally {
930+
harness.cleanup();
931+
}
932+
});
933+
894934
it('ignores a lock whose deadline is implausibly far in the future', () => {
895935
const harness = createProbeHarness();
896936

‎src/utils/usage-fetch.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -670,18 +670,26 @@ function readStaleUsageCache(cacheIdentity: UsageCacheIdentity | null): UsageDat
670670
}
671671
}
672672

673-
function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
673+
function writeUsageLock(blockedUntil: number, error: UsageLockError): string {
674+
const contents = JSON.stringify({ blockedUntil, error });
674675
try {
675676
ensureCacheDirExists();
676-
fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error }));
677+
fs.writeFileSync(LOCK_FILE, contents);
677678
} catch {
678679
// Ignore lock file errors
679680
}
681+
return contents;
680682
}
681683

682-
function clearUsageLock(): void {
684+
// Remove the lock only while it still holds the in-flight record this process
685+
// wrote. A concurrent render may have replaced it with a 429 Retry-After lock
686+
// in the meantime; deleting that would let the next render fetch inside the
687+
// server's backoff window.
688+
function clearOwnUsageLock(ownContents: string): void {
683689
try {
684-
fs.rmSync(LOCK_FILE, { force: true });
690+
if (fs.readFileSync(LOCK_FILE, 'utf8') === ownContents) {
691+
fs.rmSync(LOCK_FILE, { force: true });
692+
}
685693
} catch {
686694
// Ignore lock file errors
687695
}
@@ -896,7 +904,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
896904
);
897905
}
898906

899-
writeUsageLock(now + LOCK_MAX_AGE, 'timeout');
907+
const inFlightLock = writeUsageLock(now + LOCK_MAX_AGE, 'timeout');
900908

901909
// Fetch from API using Node's https module
902910
try {
@@ -935,7 +943,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
935943
// the caller's requested fields. Incomplete 200 responses are cached but
936944
// still need the short throttle so later renders do not refetch every time.
937945
if (hasRequiredUsageFields(usageData, requiredFields)) {
938-
clearUsageLock();
946+
clearOwnUsageLock(inFlightLock);
939947
}
940948

941949
return cacheUsageData(usageData, now);

0 commit comments

Comments
 (0)