From d8a115b049dccfa6cdda9f2244704955bd89831c Mon Sep 17 00:00:00 2001 From: luckyzd Date: Fri, 16 Oct 2026 16:38:53 +0800 Subject: [PATCH 1/2] fix(http): honor caller abort signals during retry backoff --- src/lib/http.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/http.ts | 50 +++++++++++++++---------------- 2 files changed, 93 insertions(+), 27 deletions(-) diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts index 93cdf596..bcde54af 100644 --- a/src/lib/http.test.ts +++ b/src/lib/http.test.ts @@ -1341,3 +1341,73 @@ describe('HttpClient retry-delay sleep bails on shutdown', () => { expect(calls).toBe(1); }); }); + +describe('caller abort during retry backoff', () => { + it.each(['transport', 'edge', 'api'] as const)( + 'preserves the caller reason and cancels the %s retry timer', + async kind => { + vi.useFakeTimers(); + try { + const caller = new AbortController(); + const shutdown = new AbortController(); + const add = vi.spyOn(caller.signal, 'addEventListener'); + const remove = vi.spyOn(caller.signal, 'removeEventListener'); + const fetchImpl = vi.fn(async () => { + if (kind === 'transport') throw new Error('socket closed'); + if (kind === 'edge') return new Response('Bad Gateway', { status: 502 }); + return errorEnvelopeResponse(429, 'RATE_LIMITED', { + headers: { 'content-type': 'application/json', 'retry-after': '60' }, + }); + }); + const client = new HttpClient({ + baseUrl: 'https://api.example.com/api/cli/v1', + apiKey: 'sk-user-test', + fetchImpl, + shutdownSignal: shutdown.signal, + random: () => 0, + }); + const result = client.get('/me', { signal: caller.signal }).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(1); + const reason = new Error('caller deadline expired'); + caller.abort(reason); + expect(await result).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(remove.mock.calls).toHaveLength(add.mock.calls.length); + } finally { + vi.useRealTimers(); + } + }, + ); + + it('settles and removes listeners even when an injected sleeper ignores abort', async () => { + const caller = new AbortController(); + const shutdown = new AbortController(); + const add = vi.spyOn(shutdown.signal, 'addEventListener'); + const remove = vi.spyOn(shutdown.signal, 'removeEventListener'); + let entered!: () => void; + const sleeping = new Promise(resolve => { + entered = resolve; + }); + const client = new HttpClient({ + baseUrl: 'https://api.example.com/api/cli/v1', + apiKey: 'sk-user-test', + fetchImpl: async () => { + throw new Error('offline'); + }, + shutdownSignal: shutdown.signal, + sleep: () => { + entered(); + return new Promise(() => {}); + }, + }); + const result = client.get('/me', { signal: caller.signal }).catch((err: unknown) => err); + await sleeping; + const reason = new DOMException('Cancelled', 'AbortError'); + caller.abort(reason); + expect(await result).toBe(reason); + expect(remove.mock.calls).toHaveLength(add.mock.calls.length); + }); +}); diff --git a/src/lib/http.ts b/src/lib/http.ts index 21115a5b..39881042 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -912,7 +912,7 @@ export class HttpClient { delayMs: decision.delayMs, }); requestTimeout.clear(); - await this.sleepBeforeRetry(decision.delayMs); + await this.sleepBeforeRetry(decision.delayMs, options.signal); continue; } @@ -1037,7 +1037,7 @@ export class HttpClient { delayMs: decision.delayMs, }); requestTimeout.clear(); - await this.sleepBeforeRetry(decision.delayMs); + await this.sleepBeforeRetry(decision.delayMs, options.signal); continue; } @@ -1108,7 +1108,7 @@ export class HttpClient { delayMs: decision.delayMs, }); requestTimeout.clear(); - await this.sleepBeforeRetry(decision.delayMs); + await this.sleepBeforeRetry(decision.delayMs, options.signal); } finally { requestTimeout.clear(); // Remove the manually-added abort listeners now, synchronously — @@ -1141,30 +1141,26 @@ export class HttpClient { return headers; } - /** - * Retry-delay sleep that bails the moment the shutdown signal fires: a - * RATE_LIMITED `Retry-After: 60` or a transport backoff must not delay the - * honest-detach exit by up to a minute — reject with the InterruptError - * reason immediately. Mirrors poll.ts::sleepUnlessInterrupted. - */ - private sleepBeforeRetry(ms: number): Promise { - const signal = this.shutdownSignal; - if (signal == null) return this.sleep(ms); - if (signal.aborted) return Promise.reject(signal.reason); - return new Promise((resolve, reject) => { - const onAbort = (): void => reject(signal.reason); - signal.addEventListener('abort', onAbort, { once: true }); - this.sleep(ms, signal).then( - () => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, - err => { - signal.removeEventListener('abort', onAbort); - reject(err instanceof Error ? err : new Error(String(err))); - }, - ); - }); + /** Keep caller deadlines and process shutdown active throughout retry backoff. */ + private async sleepBeforeRetry(ms: number, callerSignal?: AbortSignal): Promise { + const signals = [callerSignal, this.shutdownSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + if (signals.length === 0) return this.sleep(ms); + const { signal, cleanup } = composeAbortSignals(signals); + let onAbort: (() => void) | undefined; + try { + if (signal.aborted) throw signal.reason; + await new Promise((resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + // Also race injected sleepers that do not implement cancellation. + this.sleep(ms, signal).then(resolve, reject); + }); + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort); + cleanup(); + } } private debug(event: DebugEvent): void { From 771ab6255bb05166ea5a206be0a0841b0b7ce08d Mon Sep 17 00:00:00 2001 From: luckyzd Date: Fri, 16 Oct 2026 17:26:58 +0800 Subject: [PATCH 2/2] fix(http): preserve shutdown precedence before retry backoff Keep process interruption ahead of caller timeouts when both signals have already fired. Add six public-request regressions for transport, edge and API retry paths with SIGINT/SIGTERM exit codes. Validation: six regressions failed before the fix; 81 HTTP tests, 4596 full-suite tests and 103 local E2E tests pass. Typecheck, build, lint and formatting pass. --- src/lib/http.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/http.ts | 3 ++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts index bcde54af..7f15857b 100644 --- a/src/lib/http.test.ts +++ b/src/lib/http.test.ts @@ -1343,6 +1343,58 @@ describe('HttpClient retry-delay sleep bails on shutdown', () => { }); describe('caller abort during retry backoff', () => { + it.each([ + ['transport', 'SIGINT'], + ['transport', 'SIGTERM'], + ['edge', 'SIGINT'], + ['edge', 'SIGTERM'], + ['api', 'SIGINT'], + ['api', 'SIGTERM'], + ] as const)( + 'preserves shutdown precedence before %s retry backoff (%s)', + async (kind, signal) => { + vi.useFakeTimers(); + try { + const caller = new AbortController(); + const shutdown = new AbortController(); + const reason = new InterruptError(signal); + const sleep = vi.fn(async () => {}); + const fetchImpl = vi.fn(async () => { + if (kind === 'transport') throw new Error('socket closed'); + if (kind === 'edge') return new Response('Bad Gateway', { status: 502 }); + return errorEnvelopeResponse(429, 'RATE_LIMITED', { + headers: { 'content-type': 'application/json', 'retry-after': '60' }, + }); + }); + const client = new HttpClient({ + baseUrl: 'https://api.example.com/api/cli/v1', + apiKey: 'sk-user-test', + fetchImpl, + shutdownSignal: shutdown.signal, + sleep, + random: () => 0, + onDebug: event => { + if (event.kind === 'retry') { + // Both signals have fired by the time retry backoff starts. + caller.abort(new DOMException('Caller deadline expired', 'TimeoutError')); + shutdown.abort(reason); + } + }, + }); + const error = await client + .get('/me', { signal: caller.signal }) + .catch((err: unknown) => err); + expect(error).toBe(reason); + expect(error).toMatchObject({ exitCode: signal === 'SIGINT' ? 130 : 143 }); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }, + ); + it.each(['transport', 'edge', 'api'] as const)( 'preserves the caller reason and cancels the %s retry timer', async kind => { diff --git a/src/lib/http.ts b/src/lib/http.ts index 39881042..8dfd09aa 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -1143,7 +1143,8 @@ export class HttpClient { /** Keep caller deadlines and process shutdown active throughout retry backoff. */ private async sleepBeforeRetry(ms: number, callerSignal?: AbortSignal): Promise { - const signals = [callerSignal, this.shutdownSignal].filter( + // Match request error handling: an already-fired shutdown outranks a caller deadline. + const signals = [this.shutdownSignal, callerSignal].filter( (signal): signal is AbortSignal => signal !== undefined, ); if (signals.length === 0) return this.sleep(ms);