Skip to content

Commit 9cb3e7a

Browse files
committed
fix: time out stalled streams
1 parent 2f829ae commit 9cb3e7a

2 files changed

Lines changed: 260 additions & 12 deletions

File tree

src/client/http.ts

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,84 @@ export interface RequestOpts {
1818
authStyle?: 'bearer' | 'x-api-key';
1919
}
2020

21+
function timeoutError(message: string): DOMException {
22+
return new DOMException(message, 'TimeoutError');
23+
}
24+
25+
function withIdleTimeout(
26+
response: Response,
27+
timeoutMs: number,
28+
abortController: AbortController,
29+
): Response {
30+
if (!response.body) return response;
31+
32+
const reader = response.body.getReader();
33+
let released = false;
34+
35+
const releaseReader = (): void => {
36+
if (released) return;
37+
released = true;
38+
reader.releaseLock();
39+
};
40+
41+
const cancelRequest = async (reason?: unknown): Promise<void> => {
42+
if (!abortController.signal.aborted) abortController.abort(reason);
43+
try {
44+
await reader.cancel(reason);
45+
} finally {
46+
releaseReader();
47+
}
48+
};
49+
50+
const body = new ReadableStream<Uint8Array>({
51+
async pull(controller) {
52+
let timer: ReturnType<typeof setTimeout> | undefined;
53+
54+
try {
55+
const result = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
56+
timer = setTimeout(() => {
57+
const error = timeoutError(`Stream received no data for ${timeoutMs}ms.`);
58+
abortController.abort(error);
59+
reject(error);
60+
}, timeoutMs);
61+
62+
reader.read().then(resolve, reject);
63+
});
64+
65+
if (result.done) {
66+
releaseReader();
67+
controller.close();
68+
} else {
69+
controller.enqueue(result.value);
70+
}
71+
} catch (error) {
72+
await cancelRequest(error).catch(() => {});
73+
controller.error(error);
74+
} finally {
75+
if (timer) clearTimeout(timer);
76+
}
77+
},
78+
cancel(reason) {
79+
return cancelRequest(reason);
80+
},
81+
});
82+
83+
const timedResponse = new Response(body, {
84+
status: response.status,
85+
statusText: response.statusText,
86+
headers: response.headers,
87+
});
88+
89+
// Response's constructor does not carry fetch metadata across to a wrapped body.
90+
Object.defineProperties(timedResponse, {
91+
url: { value: response.url },
92+
redirected: { value: response.redirected },
93+
type: { value: response.type },
94+
});
95+
96+
return timedResponse;
97+
}
98+
2199
export async function request(config: Config, opts: RequestOpts): Promise<Response> {
22100
const isFormData = typeof FormData !== 'undefined' && opts.body instanceof FormData;
23101

@@ -54,16 +132,32 @@ export async function request(config: Config, opts: RequestOpts): Promise<Respon
54132

55133
const timeoutMs = (opts.timeout ?? config.timeout) * 1000;
56134

57-
const res = await fetch(opts.url, {
58-
method: opts.method ?? 'GET',
59-
headers,
60-
body: opts.body
61-
? isFormData
62-
? (opts.body as FormData)
63-
: JSON.stringify(opts.body)
64-
: undefined,
65-
signal: opts.stream ? undefined : AbortSignal.timeout(timeoutMs),
66-
});
135+
const streamAbortController = opts.stream ? new AbortController() : undefined;
136+
const headerTimeout = streamAbortController
137+
? setTimeout(() => {
138+
streamAbortController.abort(timeoutError(`Request headers were not received within ${timeoutMs}ms.`));
139+
}, timeoutMs)
140+
: undefined;
141+
142+
let res: Response;
143+
try {
144+
res = await fetch(opts.url, {
145+
method: opts.method ?? 'GET',
146+
headers,
147+
body: opts.body
148+
? isFormData
149+
? (opts.body as FormData)
150+
: JSON.stringify(opts.body)
151+
: undefined,
152+
signal: streamAbortController?.signal ?? AbortSignal.timeout(timeoutMs),
153+
});
154+
} finally {
155+
if (headerTimeout) clearTimeout(headerTimeout);
156+
}
157+
158+
if (streamAbortController) {
159+
res = withIdleTimeout(res, timeoutMs, streamAbortController);
160+
}
67161

68162
if (config.verbose) {
69163
process.stderr.write(`< ${res.status} ${res.statusText}\n`);

test/client/http.test.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, afterEach } from 'bun:test';
2-
import { requestJson } from '../../src/client/http';
1+
import { describe, it, expect, afterEach, spyOn } from 'bun:test';
2+
import { request, requestJson } from '../../src/client/http';
33
import { CLI_VERSION } from '../../src/version';
44
import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server';
55
import type { Config } from '../../src/config/schema';
@@ -93,4 +93,158 @@ describe('HTTP client', () => {
9393
requestJson(config, { url: `${server.url}/v1/test` }),
9494
).rejects.toThrow('Rate limit');
9595
});
96+
97+
it('aborts a streaming request when response headers stall', async () => {
98+
let aborted = false;
99+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((
100+
(_input: string | URL | Request, init?: RequestInit) =>
101+
new Promise<Response>((_resolve, reject) => {
102+
init?.signal?.addEventListener('abort', () => {
103+
aborted = true;
104+
reject(init.signal?.reason);
105+
}, { once: true });
106+
})
107+
) as unknown as typeof fetch);
108+
109+
try {
110+
const config = makeConfig('https://example.com');
111+
await expect(request(config, {
112+
url: 'https://example.com/stream',
113+
stream: true,
114+
timeout: 0.02,
115+
noAuth: true,
116+
})).rejects.toMatchObject({ name: 'TimeoutError' });
117+
expect(aborted).toBe(true);
118+
} finally {
119+
fetchSpy.mockRestore();
120+
}
121+
});
122+
123+
it('aborts and cancels a streaming response when its body stalls', async () => {
124+
let aborted = false;
125+
let cancelled = false;
126+
const encoder = new TextEncoder();
127+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((
128+
(_input: string | URL | Request, init?: RequestInit) => {
129+
init?.signal?.addEventListener('abort', () => {
130+
aborted = true;
131+
}, { once: true });
132+
133+
const body = new ReadableStream<Uint8Array>({
134+
start(controller) {
135+
controller.enqueue(encoder.encode('data: first\n\n'));
136+
},
137+
cancel() {
138+
cancelled = true;
139+
},
140+
});
141+
return Promise.resolve(new Response(body));
142+
}
143+
) as unknown as typeof fetch);
144+
145+
try {
146+
const config = makeConfig('https://example.com');
147+
const response = await request(config, {
148+
url: 'https://example.com/stream',
149+
stream: true,
150+
timeout: 0.02,
151+
noAuth: true,
152+
});
153+
const reader = response.body!.getReader();
154+
155+
expect(new TextDecoder().decode((await reader.read()).value)).toBe('data: first\n\n');
156+
await expect(reader.read()).rejects.toMatchObject({ name: 'TimeoutError' });
157+
expect(aborted).toBe(true);
158+
expect(cancelled).toBe(true);
159+
} finally {
160+
fetchSpy.mockRestore();
161+
}
162+
});
163+
164+
it('allows an active stream to outlive a single timeout interval', async () => {
165+
let aborted = false;
166+
const encoder = new TextEncoder();
167+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((
168+
(_input: string | URL | Request, init?: RequestInit) => {
169+
init?.signal?.addEventListener('abort', () => {
170+
aborted = true;
171+
}, { once: true });
172+
173+
let interval: ReturnType<typeof setInterval> | undefined;
174+
const body = new ReadableStream<Uint8Array>({
175+
start(controller) {
176+
let chunk = 0;
177+
interval = setInterval(() => {
178+
controller.enqueue(encoder.encode(String(chunk)));
179+
chunk += 1;
180+
if (chunk === 6) {
181+
clearInterval(interval);
182+
controller.close();
183+
}
184+
}, 10);
185+
},
186+
cancel() {
187+
if (interval) clearInterval(interval);
188+
},
189+
});
190+
return Promise.resolve(new Response(body));
191+
}
192+
) as unknown as typeof fetch);
193+
194+
try {
195+
const config = makeConfig('https://example.com');
196+
const response = await request(config, {
197+
url: 'https://example.com/stream',
198+
stream: true,
199+
timeout: 0.03,
200+
noAuth: true,
201+
});
202+
const chunks: string[] = [];
203+
204+
for await (const chunk of response.body!) {
205+
chunks.push(new TextDecoder().decode(chunk));
206+
}
207+
208+
expect(chunks).toEqual(['0', '1', '2', '3', '4', '5']);
209+
expect(aborted).toBe(false);
210+
} finally {
211+
fetchSpy.mockRestore();
212+
}
213+
});
214+
215+
it('cancels the network request when the response consumer cancels', async () => {
216+
let aborted = false;
217+
let cancelled = false;
218+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((
219+
(_input: string | URL | Request, init?: RequestInit) => {
220+
init?.signal?.addEventListener('abort', () => {
221+
aborted = true;
222+
}, { once: true });
223+
224+
const body = new ReadableStream<Uint8Array>({
225+
cancel() {
226+
cancelled = true;
227+
},
228+
});
229+
return Promise.resolve(new Response(body));
230+
}
231+
) as unknown as typeof fetch);
232+
233+
try {
234+
const config = makeConfig('https://example.com');
235+
const response = await request(config, {
236+
url: 'https://example.com/stream',
237+
stream: true,
238+
timeout: 1,
239+
noAuth: true,
240+
});
241+
242+
await response.body!.cancel('consumer stopped');
243+
244+
expect(aborted).toBe(true);
245+
expect(cancelled).toBe(true);
246+
} finally {
247+
fetchSpy.mockRestore();
248+
}
249+
});
96250
});

0 commit comments

Comments
 (0)