Skip to content

Commit cc58f6e

Browse files
authored
feat: add abort reason support (nodejs#1686)
* feat: add abort reason support * fix: set default error for node v16 * fix: jest test on v16.8 * add tests * lint
1 parent 220103d commit cc58f6e

File tree

4 files changed

+120
-26
lines changed

4 files changed

+120
-26
lines changed

lib/fetch/index.js

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,30 @@ class Fetch extends EE {
8484
this.emit('terminated', reason)
8585
}
8686

87-
abort () {
87+
// https://fetch.spec.whatwg.org/#fetch-controller-abort
88+
abort (error) {
8889
if (this.state !== 'ongoing') {
8990
return
9091
}
9192

92-
const reason = new DOMException('The operation was aborted.', 'AbortError')
93-
93+
// 1. Set controller’s state to "aborted".
9494
this.state = 'aborted'
95-
this.connection?.destroy(reason)
96-
this.emit('terminated', reason)
95+
96+
// 2. Let fallbackError be an "AbortError" DOMException.
97+
// 3. Set error to fallbackError if it is not given.
98+
if (!error) {
99+
error = new DOMException('The operation was aborted.', 'AbortError')
100+
}
101+
102+
// 4. Let serializedError be StructuredSerialize(error).
103+
// If that threw an exception, catch it, and let
104+
// serializedError be StructuredSerialize(fallbackError).
105+
106+
// 5. Set controller’s serialized abort reason to serializedError.
107+
this.serializedAbortReason = error
108+
109+
this.connection?.destroy(error)
110+
this.emit('terminated', error)
97111
}
98112
}
99113

@@ -125,8 +139,9 @@ async function fetch (input, init = {}) {
125139

126140
// 4. If requestObject’s signal’s aborted flag is set, then:
127141
if (requestObject.signal.aborted) {
128-
// 1. Abort fetch with p, request, and null.
129-
abortFetch(p, request, null)
142+
// 1. Abort the fetch() call with p, request, null, and
143+
// requestObject’s signal’s abort reason.
144+
abortFetch(p, request, null, requestObject.signal.reason)
130145

131146
// 2. Return p.
132147
return p.promise
@@ -160,8 +175,9 @@ async function fetch (input, init = {}) {
160175
// 1. Set locallyAborted to true.
161176
locallyAborted = true
162177

163-
// 2. Abort fetch with p, request, and responseObject.
164-
abortFetch(p, request, responseObject)
178+
// 2. Abort the fetch() call with p, request, responseObject,
179+
// and requestObject’s signal’s abort reason.
180+
abortFetch(p, request, responseObject, requestObject.signal.reason)
165181

166182
// 3. If controller is not null, then abort controller.
167183
if (controller != null) {
@@ -186,10 +202,16 @@ async function fetch (input, init = {}) {
186202
return
187203
}
188204

189-
// 2. If response’s aborted flag is set, then abort fetch with p,
190-
// request, and responseObject, and terminate these substeps.
205+
// 2. If response’s aborted flag is set, then:
191206
if (response.aborted) {
192-
abortFetch(p, request, responseObject)
207+
// 1. Let deserializedError be the result of deserialize a serialized
208+
// abort reason given controller’s serialized abort reason and
209+
// relevantRealm.
210+
211+
// 2. Abort the fetch() call with p, request, responseObject, and
212+
// deserializedError.
213+
214+
abortFetch(p, request, responseObject, controller.serializedAbortReason)
193215
return
194216
}
195217

@@ -297,14 +319,18 @@ function markResourceTiming (timingInfo, originalURL, initiatorType, globalThis,
297319
}
298320

299321
// https://fetch.spec.whatwg.org/#abort-fetch
300-
function abortFetch (p, request, responseObject) {
301-
// 1. Let error be an "AbortError" DOMException.
302-
const error = new DOMException('The operation was aborted.', 'AbortError')
322+
function abortFetch (p, request, responseObject, error) {
323+
// Note: AbortSignal.reason was added in node v17.2.0
324+
// which would give us an undefined error to reject with.
325+
// Remove this once node v16 is no longer supported.
326+
if (!error) {
327+
error = new DOMException('The operation was aborted.', 'AbortError')
328+
}
303329

304-
// 2. Reject promise with error.
330+
// 1. Reject promise with error.
305331
p.reject(error)
306332

307-
// 3. If request’s body is not null and is readable, then cancel request’s
333+
// 2. If request’s body is not null and is readable, then cancel request’s
308334
// body with error.
309335
if (request.body != null && isReadable(request.body?.stream)) {
310336
request.body.stream.cancel(error).catch((err) => {
@@ -316,15 +342,15 @@ function abortFetch (p, request, responseObject) {
316342
})
317343
}
318344

319-
// 4. If responseObject is null, then return.
345+
// 3. If responseObject is null, then return.
320346
if (responseObject == null) {
321347
return
322348
}
323349

324-
// 5. Let response be responseObject’s response.
350+
// 4. Let response be responseObject’s response.
325351
const response = responseObject[kState]
326352

327-
// 6. If response’s body is not null and is readable, then error response’s
353+
// 5. If response’s body is not null and is readable, then error response’s
328354
// body with error.
329355
if (response.body != null && isReadable(response.body?.stream)) {
330356
response.body.stream.cancel(error).catch((err) => {
@@ -1720,9 +1746,9 @@ async function httpNetworkFetch (
17201746
}
17211747

17221748
// 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s
1723-
// controller.
1724-
const cancelAlgorithm = () => {
1725-
fetchParams.controller.abort()
1749+
// controller with reason, given reason.
1750+
const cancelAlgorithm = (reason) => {
1751+
fetchParams.controller.abort(reason)
17261752
}
17271753

17281754
// 13. Let highWaterMark be a non-negative, non-NaN number, chosen by
@@ -1862,10 +1888,13 @@ async function httpNetworkFetch (
18621888
// 1. Set response’s aborted flag.
18631889
response.aborted = true
18641890

1865-
// 2. If stream is readable, error stream with an "AbortError" DOMException.
1891+
// 2. If stream is readable, then error stream with the result of
1892+
// deserialize a serialized abort reason given fetchParams’s
1893+
// controller’s serialized abort reason and an
1894+
// implementation-defined realm.
18661895
if (isReadable(stream)) {
18671896
fetchParams.controller.controller.error(
1868-
new DOMException('The operation was aborted.', 'AbortError')
1897+
fetchParams.controller.serializedAbortReason
18691898
)
18701899
}
18711900
} else {

test/fetch/abort.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,39 @@ test('Allow the usage of custom implementation of AbortController', async (t) =>
8888
t.equal(e.code, DOMException.ABORT_ERR)
8989
}
9090
})
91+
92+
test('allows aborting with custom errors', { skip: process.version.startsWith('v16.') }, async (t) => {
93+
const server = createServer((req, res) => {
94+
setTimeout(() => res.end(), 5000)
95+
}).listen(0)
96+
97+
t.teardown(server.close.bind(server))
98+
await once(server, 'listening')
99+
100+
t.test('Using AbortSignal.timeout', async (t) => {
101+
await t.rejects(
102+
fetch(`http://localhost:${server.address().port}`, {
103+
signal: AbortSignal.timeout(50)
104+
}),
105+
{
106+
name: 'TimeoutError',
107+
code: DOMException.TIMEOUT_ERR
108+
}
109+
)
110+
})
111+
112+
t.test('Error defaults to an AbortError DOMException', async (t) => {
113+
const ac = new AbortController()
114+
ac.abort() // no reason
115+
116+
await t.rejects(
117+
fetch(`http://localhost:${server.address().port}`, {
118+
signal: ac.signal
119+
}),
120+
{
121+
name: 'AbortError',
122+
code: DOMException.ABORT_ERR
123+
}
124+
)
125+
})
126+
})

test/jest/instanceof-error.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ runIf(nodeMajor >= 16)('Real use-case', async () => {
3737
signal: ac.signal
3838
})
3939

40-
await expect(promise).rejects.toThrowError('The operation was aborted.')
40+
await expect(promise).rejects.toThrowError(/^Th(e|is) operation was aborted\.?$/)
4141

4242
server.close()
4343
await once(server, 'close')

test/wpt/tests/fetch/api/abort/general.any.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
const BODY_METHODS = ['arrayBuffer', 'blob', 'formData', 'json', 'text'];
88

9+
const error1 = new Error('error1');
10+
error1.name = 'error1';
11+
912
// This is used to close connections that weren't correctly closed during the tests,
1013
// otherwise you can end up running out of HTTP connections.
1114
let requestAbortKeys = [];
@@ -31,6 +34,16 @@ promise_test(async t => {
3134
await promise_rejects_dom(t, "AbortError", fetchPromise);
3235
}, "Aborting rejects with AbortError");
3336

37+
promise_test(async t => {
38+
const controller = new AbortController();
39+
const signal = controller.signal;
40+
controller.abort(error1);
41+
42+
const fetchPromise = fetch('../resources/data.json', { signal });
43+
44+
await promise_rejects_exactly(t, error1, fetchPromise, 'fetch() should reject with abort reason');
45+
}, "Aborting rejects with abort reason");
46+
3447
promise_test(async t => {
3548
const controller = new AbortController();
3649
const signal = controller.signal;
@@ -91,6 +104,22 @@ promise_test(async t => {
91104
await promise_rejects_dom(t, "AbortError", fetchPromise);
92105
}, "Signal on request object");
93106

107+
promise_test(async t => {
108+
const controller = new AbortController();
109+
const signal = controller.signal;
110+
controller.abort(error1);
111+
112+
const request = new Request('../resources/data.json', { signal });
113+
114+
assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference');
115+
assert_true(request.signal.aborted, `Request's signal has aborted`);
116+
assert_equals(request.signal.reason, error1, `Request's signal's abort reason is error1`);
117+
118+
const fetchPromise = fetch(request);
119+
120+
await promise_rejects_exactly(t, error1, fetchPromise, "fetch() should reject with abort reason");
121+
}, "Signal on request object should also have abort reason");
122+
94123
promise_test(async t => {
95124
const controller = new AbortController();
96125
const signal = controller.signal;

0 commit comments

Comments
 (0)