Skip to content

Commit 6a6d2a9

Browse files
jopemachineszmarczaksindresorhus
authoredJul 24, 2022
Support AbortController (#2020)
Co-authored-by: Szymon Marczak <36894700+szmarczak@users.noreply.github.com> Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
1 parent 3207061 commit 6a6d2a9

File tree

6 files changed

+356
-0
lines changed

6 files changed

+356
-0
lines changed
 

‎documentation/2-options.md

+22
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,28 @@ await got('https://httpbin.org/anything');
209209
#### **Note:**
210210
> - If you're passing an absolute URL as `url`, you need to set `prefixUrl` to an empty string.
211211
212+
### `signal`
213+
214+
**Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)**
215+
216+
You can abort the `request` using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
217+
218+
*Requires Node.js 16 or later.*
219+
220+
```js
221+
import got from 'got';
222+
223+
const abortController = new AbortController();
224+
225+
const request = got('https://httpbin.org/anything', {
226+
signal: abortController.signal
227+
});
228+
229+
setTimeout(() => {
230+
abortController.abort();
231+
}, 100);
232+
```
233+
212234
### `method`
213235

214236
**Type: `string`**\

‎documentation/8-errors.md

+6
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,9 @@ When the request is aborted with `promise.cancel()`.
9797
**Code: `ERR_RETRYING`**
9898

9999
Always triggers a new retry when thrown.
100+
101+
### `AbortError`
102+
103+
**Code: `ERR_ABORTED`**
104+
105+
When the request is aborted with [AbortController.abort()](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort).

‎source/core/errors.ts

+11
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,14 @@ export class RetryError extends RequestError {
170170
this.code = 'ERR_RETRYING';
171171
}
172172
}
173+
174+
/**
175+
An error to be thrown when the request is aborted by AbortController.
176+
*/
177+
export class AbortError extends RequestError {
178+
constructor(request: Request) {
179+
super('This operation was aborted.', {}, request);
180+
this.code = 'ERR_ABORTED';
181+
this.name = 'AbortError';
182+
}
183+
}

‎source/core/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
TimeoutError,
3333
UploadError,
3434
CacheError,
35+
AbortError,
3536
} from './errors.js';
3637
import type {PlainResponse} from './response.js';
3738
import type {PromiseCookieJar, NativeRequestOptions, RetryOptions} from './options.js';
@@ -241,6 +242,14 @@ export default class Request extends Duplex implements RequestEvents<Request> {
241242
return;
242243
}
243244

245+
if (this.options.signal?.aborted) {
246+
this.destroy(new AbortError(this));
247+
}
248+
249+
this.options.signal?.addEventListener('abort', () => {
250+
this.destroy(new AbortError(this));
251+
});
252+
244253
// Important! If you replace `body` in a handler with another stream, make sure it's readable first.
245254
// The below is run only once.
246255
const {body} = this.options;

‎source/core/options.ts

+34
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ const defaultInternals: Options['_internals'] = {
827827
},
828828
setHost: true,
829829
maxHeaderSize: undefined,
830+
signal: undefined,
830831
enableUnixSockets: true,
831832
};
832833

@@ -1489,6 +1490,38 @@ export default class Options {
14891490
}
14901491
}
14911492

1493+
/**
1494+
You can abort the `request` using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
1495+
1496+
*Requires Node.js 16 or later.*
1497+
1498+
@example
1499+
```
1500+
import got from 'got';
1501+
1502+
const abortController = new AbortController();
1503+
1504+
const request = got('https://httpbin.org/anything', {
1505+
signal: abortController.signal
1506+
});
1507+
1508+
setTimeout(() => {
1509+
abortController.abort();
1510+
}, 100);
1511+
```
1512+
*/
1513+
// TODO: Replace `any` with `AbortSignal` when targeting Node 16.
1514+
get signal(): any | undefined {
1515+
return this._internals.signal;
1516+
}
1517+
1518+
// TODO: Replace `any` with `AbortSignal` when targeting Node 16.
1519+
set signal(value: any | undefined) {
1520+
assert.object(value);
1521+
1522+
this._internals.signal = value;
1523+
}
1524+
14921525
/**
14931526
Ignore invalid cookies instead of throwing an error.
14941527
Only useful when the `cookieJar` option has been set. Not recommended.
@@ -2488,5 +2521,6 @@ export default class Options {
24882521
Object.freeze(options.retry.methods);
24892522
Object.freeze(options.retry.statusCodes);
24902523
Object.freeze(options.context);
2524+
Object.freeze(options.signal);
24912525
}
24922526
}

‎test/abort.ts

+274
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import process from 'process';
2+
import {EventEmitter} from 'events';
3+
import stream, {Readable as ReadableStream} from 'stream';
4+
import test from 'ava';
5+
import delay from 'delay';
6+
import {pEvent} from 'p-event';
7+
import {Handler} from 'express';
8+
import got from '../source/index.js';
9+
import slowDataStream from './helpers/slow-data-stream.js';
10+
import {GlobalClock} from './helpers/types.js';
11+
import {ExtendedHttpTestServer} from './helpers/create-http-test-server.js';
12+
import withServer, {withServerAndFakeTimers} from './helpers/with-server.js';
13+
14+
if (globalThis.AbortController !== undefined) {
15+
const prepareServer = (server: ExtendedHttpTestServer, clock: GlobalClock): {emitter: EventEmitter; promise: Promise<unknown>} => {
16+
const emitter = new EventEmitter();
17+
18+
const promise = new Promise<void>((resolve, reject) => {
19+
server.all('/abort', async (request, response) => {
20+
emitter.emit('connection');
21+
22+
request.once('aborted', resolve);
23+
response.once('finish', reject.bind(null, new Error('Request finished instead of aborting.')));
24+
25+
try {
26+
await pEvent(request, 'end');
27+
} catch {
28+
// Node.js 15.0.0 throws AND emits `aborted`
29+
}
30+
31+
response.end();
32+
});
33+
34+
server.get('/redirect', (_request, response) => {
35+
response.writeHead(302, {
36+
location: `${server.url}/abort`,
37+
});
38+
response.end();
39+
40+
emitter.emit('sentRedirect');
41+
42+
clock.tick(3000);
43+
resolve();
44+
});
45+
});
46+
47+
return {emitter, promise};
48+
};
49+
50+
const downloadHandler = (clock?: GlobalClock): Handler => (_request, response) => {
51+
response.writeHead(200, {
52+
'transfer-encoding': 'chunked',
53+
});
54+
55+
response.flushHeaders();
56+
57+
stream.pipeline(
58+
slowDataStream(clock),
59+
response,
60+
() => {
61+
response.end();
62+
},
63+
);
64+
};
65+
66+
test.serial('does not retry after abort', withServerAndFakeTimers, async (t, server, got, clock) => {
67+
const {emitter, promise} = prepareServer(server, clock);
68+
const controller = new AbortController();
69+
70+
const gotPromise = got('redirect', {
71+
signal: controller.signal,
72+
retry: {
73+
calculateDelay() {
74+
t.fail('Makes a new try after abort');
75+
return 0;
76+
},
77+
},
78+
});
79+
80+
emitter.once('sentRedirect', () => {
81+
controller.abort();
82+
});
83+
84+
await t.throwsAsync(gotPromise, {
85+
code: 'ERR_ABORTED',
86+
message: 'This operation was aborted.',
87+
});
88+
89+
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
90+
});
91+
92+
test.serial('abort request timeouts', withServer, async (t, server, got) => {
93+
server.get('/', () => {});
94+
95+
const controller = new AbortController();
96+
97+
const gotPromise = got({
98+
signal: controller.signal,
99+
timeout: {
100+
request: 10,
101+
},
102+
retry: {
103+
calculateDelay({computedValue}) {
104+
process.nextTick(() => {
105+
controller.abort();
106+
});
107+
108+
if (computedValue) {
109+
return 20;
110+
}
111+
112+
return 0;
113+
},
114+
limit: 1,
115+
},
116+
});
117+
118+
await t.throwsAsync(gotPromise, {
119+
code: 'ERR_ABORTED',
120+
message: 'This operation was aborted.',
121+
});
122+
123+
// Wait for unhandled errors
124+
await delay(40);
125+
});
126+
127+
test.serial('aborts in-progress request', withServerAndFakeTimers, async (t, server, got, clock) => {
128+
const {emitter, promise} = prepareServer(server, clock);
129+
130+
const controller = new AbortController();
131+
132+
const body = new ReadableStream({
133+
read() {},
134+
});
135+
body.push('1');
136+
137+
const gotPromise = got.post('abort', {body, signal: controller.signal});
138+
139+
// Wait for the connection to be established before canceling
140+
emitter.once('connection', () => {
141+
controller.abort();
142+
body.push(null);
143+
});
144+
145+
await t.throwsAsync(gotPromise, {
146+
code: 'ERR_ABORTED',
147+
message: 'This operation was aborted.',
148+
});
149+
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
150+
});
151+
152+
test.serial('aborts in-progress request with timeout', withServerAndFakeTimers, async (t, server, got, clock) => {
153+
const {emitter, promise} = prepareServer(server, clock);
154+
155+
const controller = new AbortController();
156+
157+
const body = new ReadableStream({
158+
read() {},
159+
});
160+
body.push('1');
161+
162+
const gotPromise = got.post('abort', {body, timeout: {request: 10_000}, signal: controller.signal});
163+
164+
// Wait for the connection to be established before canceling
165+
emitter.once('connection', () => {
166+
controller.abort();
167+
body.push(null);
168+
});
169+
170+
await t.throwsAsync(gotPromise, {
171+
code: 'ERR_ABORTED',
172+
message: 'This operation was aborted.',
173+
});
174+
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
175+
});
176+
177+
test.serial('abort immediately', withServerAndFakeTimers, async (t, server, got, clock) => {
178+
const controller = new AbortController();
179+
180+
const promise = new Promise<void>((resolve, reject) => {
181+
// We won't get an abort or even a connection
182+
// We assume no request within 1000ms equals a (client side) aborted request
183+
server.get('/abort', (_request, response) => {
184+
response.once('finish', reject.bind(global, new Error('Request finished instead of aborting.')));
185+
response.end();
186+
});
187+
188+
clock.tick(1000);
189+
resolve();
190+
});
191+
192+
const gotPromise = got('abort', {signal: controller.signal});
193+
controller.abort();
194+
195+
await t.throwsAsync(gotPromise, {
196+
code: 'ERR_ABORTED',
197+
message: 'This operation was aborted.',
198+
});
199+
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
200+
});
201+
202+
test('recover from abort using abortable promise attribute', async t => {
203+
// Abort before connection started
204+
const controller = new AbortController();
205+
206+
const p = got('http://example.com', {signal: controller.signal});
207+
const recover = p.catch((error: Error) => {
208+
if (controller.signal.aborted) {
209+
return;
210+
}
211+
212+
throw error;
213+
});
214+
215+
controller.abort();
216+
217+
await t.notThrowsAsync(recover);
218+
});
219+
220+
test('recover from abort using error instance', async t => {
221+
const controller = new AbortController();
222+
223+
const p = got('http://example.com', {signal: controller.signal});
224+
const recover = p.catch((error: Error) => {
225+
if (error.message === 'This operation was aborted.') {
226+
return;
227+
}
228+
229+
throw error;
230+
});
231+
232+
controller.abort();
233+
234+
await t.notThrowsAsync(recover);
235+
});
236+
237+
// TODO: Use `fakeTimers` here
238+
test.serial('throws on incomplete (aborted) response', withServer, async (t, server, got) => {
239+
server.get('/', downloadHandler());
240+
241+
const controller = new AbortController();
242+
243+
const promise = got('', {signal: controller.signal});
244+
245+
setTimeout(() => {
246+
controller.abort();
247+
}, 400);
248+
249+
await t.throwsAsync(promise, {
250+
code: 'ERR_ABORTED',
251+
message: 'This operation was aborted.',
252+
});
253+
});
254+
255+
test('throws when aborting cached request', withServer, async (t, server, got) => {
256+
server.get('/', (_request, response) => {
257+
response.setHeader('Cache-Control', 'public, max-age=60');
258+
response.end(Date.now().toString());
259+
});
260+
261+
const cache = new Map();
262+
263+
await got({cache});
264+
265+
const controller = new AbortController();
266+
const promise = got({cache, signal: controller.signal});
267+
controller.abort();
268+
269+
await t.throwsAsync(promise, {
270+
code: 'ERR_ABORTED',
271+
message: 'This operation was aborted.',
272+
});
273+
});
274+
}

0 commit comments

Comments
 (0)
Please sign in to comment.