Skip to content

Commit

Permalink
feat: add abort reason support (#1686)
Browse files Browse the repository at this point in the history
* feat: add abort reason support

* fix: set default error for node v16

* fix: jest test on v16.8

* add tests

* lint
  • Loading branch information
KhafraDev committed Oct 17, 2022
1 parent 220103d commit cc58f6e
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 26 deletions.
79 changes: 54 additions & 25 deletions lib/fetch/index.js
Expand Up @@ -84,16 +84,30 @@ class Fetch extends EE {
this.emit('terminated', reason)
}

abort () {
// https://fetch.spec.whatwg.org/#fetch-controller-abort
abort (error) {
if (this.state !== 'ongoing') {
return
}

const reason = new DOMException('The operation was aborted.', 'AbortError')

// 1. Set controller’s state to "aborted".
this.state = 'aborted'
this.connection?.destroy(reason)
this.emit('terminated', reason)

// 2. Let fallbackError be an "AbortError" DOMException.
// 3. Set error to fallbackError if it is not given.
if (!error) {
error = new DOMException('The operation was aborted.', 'AbortError')
}

// 4. Let serializedError be StructuredSerialize(error).
// If that threw an exception, catch it, and let
// serializedError be StructuredSerialize(fallbackError).

// 5. Set controller’s serialized abort reason to serializedError.
this.serializedAbortReason = error

this.connection?.destroy(error)
this.emit('terminated', error)
}
}

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

// 4. If requestObject’s signal’s aborted flag is set, then:
if (requestObject.signal.aborted) {
// 1. Abort fetch with p, request, and null.
abortFetch(p, request, null)
// 1. Abort the fetch() call with p, request, null, and
// requestObject’s signal’s abort reason.
abortFetch(p, request, null, requestObject.signal.reason)

// 2. Return p.
return p.promise
Expand Down Expand Up @@ -160,8 +175,9 @@ async function fetch (input, init = {}) {
// 1. Set locallyAborted to true.
locallyAborted = true

// 2. Abort fetch with p, request, and responseObject.
abortFetch(p, request, responseObject)
// 2. Abort the fetch() call with p, request, responseObject,
// and requestObject’s signal’s abort reason.
abortFetch(p, request, responseObject, requestObject.signal.reason)

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

// 2. If response’s aborted flag is set, then abort fetch with p,
// request, and responseObject, and terminate these substeps.
// 2. If response’s aborted flag is set, then:
if (response.aborted) {
abortFetch(p, request, responseObject)
// 1. Let deserializedError be the result of deserialize a serialized
// abort reason given controller’s serialized abort reason and
// relevantRealm.

// 2. Abort the fetch() call with p, request, responseObject, and
// deserializedError.

abortFetch(p, request, responseObject, controller.serializedAbortReason)
return
}

Expand Down Expand Up @@ -297,14 +319,18 @@ function markResourceTiming (timingInfo, originalURL, initiatorType, globalThis,
}

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

// 2. Reject promise with error.
// 1. Reject promise with error.
p.reject(error)

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

// 4. If responseObject is null, then return.
// 3. If responseObject is null, then return.
if (responseObject == null) {
return
}

// 5. Let response be responseObject’s response.
// 4. Let response be responseObject’s response.
const response = responseObject[kState]

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

// 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s
// controller.
const cancelAlgorithm = () => {
fetchParams.controller.abort()
// controller with reason, given reason.
const cancelAlgorithm = (reason) => {
fetchParams.controller.abort(reason)
}

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

// 2. If stream is readable, error stream with an "AbortError" DOMException.
// 2. If stream is readable, then error stream with the result of
// deserialize a serialized abort reason given fetchParams’s
// controller’s serialized abort reason and an
// implementation-defined realm.
if (isReadable(stream)) {
fetchParams.controller.controller.error(
new DOMException('The operation was aborted.', 'AbortError')
fetchParams.controller.serializedAbortReason
)
}
} else {
Expand Down
36 changes: 36 additions & 0 deletions test/fetch/abort.js
Expand Up @@ -88,3 +88,39 @@ test('Allow the usage of custom implementation of AbortController', async (t) =>
t.equal(e.code, DOMException.ABORT_ERR)
}
})

test('allows aborting with custom errors', { skip: process.version.startsWith('v16.') }, async (t) => {
const server = createServer((req, res) => {
setTimeout(() => res.end(), 5000)
}).listen(0)

t.teardown(server.close.bind(server))
await once(server, 'listening')

t.test('Using AbortSignal.timeout', async (t) => {
await t.rejects(
fetch(`http://localhost:${server.address().port}`, {
signal: AbortSignal.timeout(50)
}),
{
name: 'TimeoutError',
code: DOMException.TIMEOUT_ERR
}
)
})

t.test('Error defaults to an AbortError DOMException', async (t) => {
const ac = new AbortController()
ac.abort() // no reason

await t.rejects(
fetch(`http://localhost:${server.address().port}`, {
signal: ac.signal
}),
{
name: 'AbortError',
code: DOMException.ABORT_ERR
}
)
})
})
2 changes: 1 addition & 1 deletion test/jest/instanceof-error.test.js
Expand Up @@ -37,7 +37,7 @@ runIf(nodeMajor >= 16)('Real use-case', async () => {
signal: ac.signal
})

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

server.close()
await once(server, 'close')
Expand Down
29 changes: 29 additions & 0 deletions test/wpt/tests/fetch/api/abort/general.any.js
Expand Up @@ -6,6 +6,9 @@

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

const error1 = new Error('error1');
error1.name = 'error1';

// This is used to close connections that weren't correctly closed during the tests,
// otherwise you can end up running out of HTTP connections.
let requestAbortKeys = [];
Expand All @@ -31,6 +34,16 @@ promise_test(async t => {
await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Aborting rejects with AbortError");

promise_test(async t => {
const controller = new AbortController();
const signal = controller.signal;
controller.abort(error1);

const fetchPromise = fetch('../resources/data.json', { signal });

await promise_rejects_exactly(t, error1, fetchPromise, 'fetch() should reject with abort reason');
}, "Aborting rejects with abort reason");

promise_test(async t => {
const controller = new AbortController();
const signal = controller.signal;
Expand Down Expand Up @@ -91,6 +104,22 @@ promise_test(async t => {
await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal on request object");

promise_test(async t => {
const controller = new AbortController();
const signal = controller.signal;
controller.abort(error1);

const request = new Request('../resources/data.json', { signal });

assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference');
assert_true(request.signal.aborted, `Request's signal has aborted`);
assert_equals(request.signal.reason, error1, `Request's signal's abort reason is error1`);

const fetchPromise = fetch(request);

await promise_rejects_exactly(t, error1, fetchPromise, "fetch() should reject with abort reason");
}, "Signal on request object should also have abort reason");

promise_test(async t => {
const controller = new AbortController();
const signal = controller.signal;
Expand Down

0 comments on commit cc58f6e

Please sign in to comment.