Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/lib/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,3 +1341,125 @@ describe('HttpClient retry-delay sleep bails on shutdown', () => {
expect(calls).toBe(1);
});
});

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 => {
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<void>(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<void>(() => {});
},
});
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);
});
});
51 changes: 24 additions & 27 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -1141,30 +1141,27 @@ 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<void> {
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<void> {
// 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);
const { signal, cleanup } = composeAbortSignals(signals);
let onAbort: (() => void) | undefined;
try {
if (signal.aborted) throw signal.reason;
await new Promise<void>((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 {
Expand Down
Loading