Add support for AbortSignal to cancel requests (#539)
Thx @jnields @FrogTheFrog @TimothyGu for their work!
This commit is contained in:
parent
1daae67e9e
commit
ecd3d52c55
|
@ -6,7 +6,17 @@ Because `window.fetch` isn't designed to be transparent about the cause of reque
|
||||||
|
|
||||||
The basics:
|
The basics:
|
||||||
|
|
||||||
- All [operational errors][joyent-guide] are rejected with a [FetchError](https://github.com/bitinn/node-fetch/blob/master/README.md#class-fetcherror). You can handle them all through the promise `catch` clause.
|
- A cancelled request is rejected with an [`AbortError`](https://github.com/bitinn/node-fetch/blob/master/README.md#class-aborterror). You can check if the reason for rejection was that the request was aborted by checking the `Error`'s `name` is `AbortError`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
fetch(url, { signal }).catch(err => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
// request was aborted
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- All [operational errors][joyent-guide] *other than aborted requests* are rejected with a [FetchError](https://github.com/bitinn/node-fetch/blob/master/README.md#class-fetcherror). You can handle them all through the promise `catch` clause.
|
||||||
|
|
||||||
- All errors come with an `err.message` detailing the cause of errors.
|
- All errors come with an `err.message` detailing the cause of errors.
|
||||||
|
|
||||||
|
|
48
README.md
48
README.md
|
@ -118,7 +118,7 @@ fetch('https://httpbin.org/post', { method: 'POST', body: 'a=1' })
|
||||||
```js
|
```js
|
||||||
const body = { a: 1 };
|
const body = { a: 1 };
|
||||||
|
|
||||||
fetch('https://httpbin.org/post', {
|
fetch('https://httpbin.org/post', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
@ -275,16 +275,51 @@ The default values are shown after each option key.
|
||||||
headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below)
|
headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below)
|
||||||
body: null, // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream
|
body: null, // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream
|
||||||
redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect
|
redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect
|
||||||
|
signal: null, // pass an instance of AbortSignal to optionally abort requests
|
||||||
|
|
||||||
// The following properties are node-fetch extensions
|
// The following properties are node-fetch extensions
|
||||||
follow: 20, // maximum redirect count. 0 to not follow redirect
|
follow: 20, // maximum redirect count. 0 to not follow redirect
|
||||||
timeout: 0, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies)
|
timeout: 0, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies). Signal is recommended instead.
|
||||||
compress: true, // support gzip/deflate content encoding. false to disable
|
compress: true, // support gzip/deflate content encoding. false to disable
|
||||||
size: 0, // maximum response body size in bytes. 0 to disable
|
size: 0, // maximum response body size in bytes. 0 to disable
|
||||||
agent: null // http(s).Agent instance, allows custom proxy, certificate, dns lookup etc.
|
agent: null // http(s).Agent instance, allows custom proxy, certificate, dns lookup etc.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Request cancellation with AbortController:
|
||||||
|
|
||||||
|
> NOTE: You may only cancel streamed requests on Node >= v8.0.0
|
||||||
|
|
||||||
|
You may cancel requests with `AbortController`. A suggested implementation is [`abort-controller`](https://www.npmjs.com/package/abort-controller).
|
||||||
|
|
||||||
|
An example of timing out a request after 150ms could be achieved as follows:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import AbortContoller from 'abort-controller';
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => { controller.abort(); },
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
|
||||||
|
fetch(url, { signal: controller.signal })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(
|
||||||
|
data => {
|
||||||
|
useData(data)
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
// request was aborted
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
##### Default Headers
|
##### Default Headers
|
||||||
|
|
||||||
If no values are set, the following request headers will be sent automatically:
|
If no values are set, the following request headers will be sent automatically:
|
||||||
|
@ -463,6 +498,13 @@ Identical to `body.text()`, except instead of always converting to UTF-8, encodi
|
||||||
|
|
||||||
An operational error in the fetching process. See [ERROR-HANDLING.md][] for more info.
|
An operational error in the fetching process. See [ERROR-HANDLING.md][] for more info.
|
||||||
|
|
||||||
|
<a id="class-aborterror"></a>
|
||||||
|
### Class: AbortError
|
||||||
|
|
||||||
|
<small>*(node-fetch extension)*</small>
|
||||||
|
|
||||||
|
An Error thrown when the request is aborted in response to an `AbortSignal`'s `abort` event. It has a `name` property of `AbortError`. See [ERROR-HANDLING.MD][] for more info.
|
||||||
|
|
||||||
## Acknowledgement
|
## Acknowledgement
|
||||||
|
|
||||||
Thanks to [github/fetch](https://github.com/github/fetch) for providing a solid implementation reference.
|
Thanks to [github/fetch](https://github.com/github/fetch) for providing a solid implementation reference.
|
||||||
|
@ -487,4 +529,4 @@ MIT
|
||||||
[mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
[mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
||||||
[LIMITS.md]: https://github.com/bitinn/node-fetch/blob/master/LIMITS.md
|
[LIMITS.md]: https://github.com/bitinn/node-fetch/blob/master/LIMITS.md
|
||||||
[ERROR-HANDLING.md]: https://github.com/bitinn/node-fetch/blob/master/ERROR-HANDLING.md
|
[ERROR-HANDLING.md]: https://github.com/bitinn/node-fetch/blob/master/ERROR-HANDLING.md
|
||||||
[UPGRADE-GUIDE.md]: https://github.com/bitinn/node-fetch/blob/master/UPGRADE-GUIDE.md
|
[UPGRADE-GUIDE.md]: https://github.com/bitinn/node-fetch/blob/master/UPGRADE-GUIDE.md
|
||||||
|
|
|
@ -37,6 +37,8 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/bitinn/node-fetch",
|
"homepage": "https://github.com/bitinn/node-fetch",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"abort-controller": "^1.0.2",
|
||||||
|
"abortcontroller-polyfill": "^1.1.9",
|
||||||
"babel-core": "^6.26.0",
|
"babel-core": "^6.26.0",
|
||||||
"babel-plugin-istanbul": "^4.1.5",
|
"babel-plugin-istanbul": "^4.1.5",
|
||||||
"babel-preset-env": "^1.6.1",
|
"babel-preset-env": "^1.6.1",
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* abort-error.js
|
||||||
|
*
|
||||||
|
* AbortError interface for cancelled requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AbortError instance
|
||||||
|
*
|
||||||
|
* @param String message Error message for human
|
||||||
|
* @return AbortError
|
||||||
|
*/
|
||||||
|
export default function AbortError(message) {
|
||||||
|
Error.call(this, message);
|
||||||
|
|
||||||
|
this.type = 'aborted';
|
||||||
|
this.message = message;
|
||||||
|
|
||||||
|
// hide custom error implementation details from end-users
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
|
||||||
|
AbortError.prototype = Object.create(Error.prototype);
|
||||||
|
AbortError.prototype.constructor = AbortError;
|
||||||
|
AbortError.prototype.name = 'AbortError';
|
16
src/body.js
16
src/body.js
|
@ -63,7 +63,10 @@ export default function Body(body, {
|
||||||
|
|
||||||
if (body instanceof Stream) {
|
if (body instanceof Stream) {
|
||||||
body.on('error', err => {
|
body.on('error', err => {
|
||||||
this[INTERNALS].error = new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err);
|
const error = err.name === 'AbortError'
|
||||||
|
? err
|
||||||
|
: new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err);
|
||||||
|
this[INTERNALS].error = error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -240,9 +243,16 @@ function consumeBody() {
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle stream error, such as incorrect content-encoding
|
// handle stream errors
|
||||||
this.body.on('error', err => {
|
this.body.on('error', err => {
|
||||||
reject(new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err));
|
if (err.name === 'AbortError') {
|
||||||
|
// if the request was aborted, reject with this Error
|
||||||
|
abort = true;
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
// other errors, such as incorrect content-encoding
|
||||||
|
reject(new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.body.on('data', chunk => {
|
this.body.on('data', chunk => {
|
||||||
|
|
47
src/index.js
47
src/index.js
|
@ -18,6 +18,7 @@ import Response from './response';
|
||||||
import Headers, { createHeadersLenient } from './headers';
|
import Headers, { createHeadersLenient } from './headers';
|
||||||
import Request, { getNodeRequestOptions } from './request';
|
import Request, { getNodeRequestOptions } from './request';
|
||||||
import FetchError from './fetch-error';
|
import FetchError from './fetch-error';
|
||||||
|
import AbortError from './abort-error';
|
||||||
|
|
||||||
// fix an issue where "PassThrough", "resolve" aren't a named export for node <10
|
// fix an issue where "PassThrough", "resolve" aren't a named export for node <10
|
||||||
const PassThrough = Stream.PassThrough;
|
const PassThrough = Stream.PassThrough;
|
||||||
|
@ -46,13 +47,40 @@ export default function fetch(url, opts) {
|
||||||
const options = getNodeRequestOptions(request);
|
const options = getNodeRequestOptions(request);
|
||||||
|
|
||||||
const send = (options.protocol === 'https:' ? https : http).request;
|
const send = (options.protocol === 'https:' ? https : http).request;
|
||||||
|
const { signal } = request;
|
||||||
|
let response = null;
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
let error = new AbortError('The user aborted a request.');
|
||||||
|
reject(error);
|
||||||
|
if (request.body && request.body instanceof Stream.Readable) {
|
||||||
|
request.body.destroy(error);
|
||||||
|
}
|
||||||
|
if (!response || !response.body) return;
|
||||||
|
response.body.emit('error', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal && signal.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortAndFinalize = () => {
|
||||||
|
abort();
|
||||||
|
finalize();
|
||||||
|
}
|
||||||
|
|
||||||
// send request
|
// send request
|
||||||
const req = send(options);
|
const req = send(options);
|
||||||
let reqTimeout;
|
let reqTimeout;
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', abortAndFinalize);
|
||||||
|
}
|
||||||
|
|
||||||
function finalize() {
|
function finalize() {
|
||||||
req.abort();
|
req.abort();
|
||||||
|
if (signal) signal.removeEventListener('abort', abortAndFinalize);
|
||||||
clearTimeout(reqTimeout);
|
clearTimeout(reqTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,7 +145,8 @@ export default function fetch(url, opts) {
|
||||||
agent: request.agent,
|
agent: request.agent,
|
||||||
compress: request.compress,
|
compress: request.compress,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body: request.body
|
body: request.body,
|
||||||
|
signal: request.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// HTTP-redirect fetch step 9
|
// HTTP-redirect fetch step 9
|
||||||
|
@ -142,7 +171,11 @@ export default function fetch(url, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepare response
|
// prepare response
|
||||||
|
res.once('end', () => {
|
||||||
|
if (signal) signal.removeEventListener('abort', abortAndFinalize);
|
||||||
|
});
|
||||||
let body = res.pipe(new PassThrough());
|
let body = res.pipe(new PassThrough());
|
||||||
|
|
||||||
const response_options = {
|
const response_options = {
|
||||||
url: request.url,
|
url: request.url,
|
||||||
status: res.statusCode,
|
status: res.statusCode,
|
||||||
|
@ -164,7 +197,8 @@ export default function fetch(url, opts) {
|
||||||
// 4. no content response (204)
|
// 4. no content response (204)
|
||||||
// 5. content not modified response (304)
|
// 5. content not modified response (304)
|
||||||
if (!request.compress || request.method === 'HEAD' || codings === null || res.statusCode === 204 || res.statusCode === 304) {
|
if (!request.compress || request.method === 'HEAD' || codings === null || res.statusCode === 204 || res.statusCode === 304) {
|
||||||
resolve(new Response(body, response_options));
|
response = new Response(body, response_options);
|
||||||
|
resolve(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,7 +215,8 @@ export default function fetch(url, opts) {
|
||||||
// for gzip
|
// for gzip
|
||||||
if (codings == 'gzip' || codings == 'x-gzip') {
|
if (codings == 'gzip' || codings == 'x-gzip') {
|
||||||
body = body.pipe(zlib.createGunzip(zlibOptions));
|
body = body.pipe(zlib.createGunzip(zlibOptions));
|
||||||
resolve(new Response(body, response_options));
|
response = new Response(body, response_options);
|
||||||
|
resolve(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,13 +232,15 @@ export default function fetch(url, opts) {
|
||||||
} else {
|
} else {
|
||||||
body = body.pipe(zlib.createInflateRaw());
|
body = body.pipe(zlib.createInflateRaw());
|
||||||
}
|
}
|
||||||
resolve(new Response(body, response_options));
|
response = new Response(body, response_options);
|
||||||
|
resolve(response);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, use response as-is
|
// otherwise, use response as-is
|
||||||
resolve(new Response(body, response_options));
|
response = new Response(body, response_options);
|
||||||
|
resolve(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
writeToStream(req, request);
|
writeToStream(req, request);
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Url from 'url';
|
import Url from 'url';
|
||||||
|
import Stream from 'stream';
|
||||||
import Headers, { exportNodeCompatibleHeaders } from './headers.js';
|
import Headers, { exportNodeCompatibleHeaders } from './headers.js';
|
||||||
import Body, { clone, extractContentType, getTotalBytes } from './body';
|
import Body, { clone, extractContentType, getTotalBytes } from './body';
|
||||||
|
|
||||||
|
@ -18,6 +18,8 @@ const INTERNALS = Symbol('Request internals');
|
||||||
const parse_url = Url.parse;
|
const parse_url = Url.parse;
|
||||||
const format_url = Url.format;
|
const format_url = Url.format;
|
||||||
|
|
||||||
|
const streamDestructionSupported = 'destroy' in Stream.Readable.prototype;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a value is an instance of Request.
|
* Check if a value is an instance of Request.
|
||||||
*
|
*
|
||||||
|
@ -31,6 +33,15 @@ function isRequest(input) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbortSignal(signal) {
|
||||||
|
const proto = (
|
||||||
|
signal
|
||||||
|
&& typeof signal === 'object'
|
||||||
|
&& Object.getPrototypeOf(signal)
|
||||||
|
);
|
||||||
|
return !!(proto && proto.constructor.name === 'AbortSignal');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request class
|
* Request class
|
||||||
*
|
*
|
||||||
|
@ -86,11 +97,21 @@ export default class Request {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let signal = isRequest(input)
|
||||||
|
? input.signal
|
||||||
|
: null;
|
||||||
|
if ('signal' in init) signal = init.signal
|
||||||
|
|
||||||
|
if (signal != null && !isAbortSignal(signal)) {
|
||||||
|
throw new TypeError('Expected signal to be an instanceof AbortSignal');
|
||||||
|
}
|
||||||
|
|
||||||
this[INTERNALS] = {
|
this[INTERNALS] = {
|
||||||
method,
|
method,
|
||||||
redirect: init.redirect || input.redirect || 'follow',
|
redirect: init.redirect || input.redirect || 'follow',
|
||||||
headers,
|
headers,
|
||||||
parsedURL
|
parsedURL,
|
||||||
|
signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// node-fetch-only options
|
// node-fetch-only options
|
||||||
|
@ -120,6 +141,10 @@ export default class Request {
|
||||||
return this[INTERNALS].redirect;
|
return this[INTERNALS].redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get signal() {
|
||||||
|
return this[INTERNALS].signal;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone this request
|
* Clone this request
|
||||||
*
|
*
|
||||||
|
@ -144,7 +169,8 @@ Object.defineProperties(Request.prototype, {
|
||||||
url: { enumerable: true },
|
url: { enumerable: true },
|
||||||
headers: { enumerable: true },
|
headers: { enumerable: true },
|
||||||
redirect: { enumerable: true },
|
redirect: { enumerable: true },
|
||||||
clone: { enumerable: true }
|
clone: { enumerable: true },
|
||||||
|
signal: { enumerable: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -171,6 +197,14 @@ export function getNodeRequestOptions(request) {
|
||||||
throw new TypeError('Only HTTP(S) protocols are supported');
|
throw new TypeError('Only HTTP(S) protocols are supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.signal
|
||||||
|
&& request.body instanceof Stream.Readable
|
||||||
|
&& !streamDestructionSupported
|
||||||
|
) {
|
||||||
|
throw new Error('Cancellation of streamed requests with AbortSignal is not supported in node < 8');
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP-network-or-cache fetch steps 2.4-2.7
|
// HTTP-network-or-cache fetch steps 2.4-2.7
|
||||||
let contentLengthValue = null;
|
let contentLengthValue = null;
|
||||||
if (request.body == null && /^(POST|PUT)$/i.test(request.method)) {
|
if (request.body == null && /^(POST|PUT)$/i.test(request.method)) {
|
||||||
|
|
|
@ -269,6 +269,20 @@ export default class TestServer {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (p === '/redirect/slow') {
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.setHeader('Location', '/redirect/301');
|
||||||
|
setTimeout(function() {
|
||||||
|
res.end();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/redirect/slow-stream') {
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.setHeader('Location', '/slow');
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
if (p === '/error/400') {
|
if (p === '/error/400') {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
|
284
test/test.js
284
test/test.js
|
@ -10,6 +10,8 @@ import FormData from 'form-data';
|
||||||
import stringToArrayBuffer from 'string-to-arraybuffer';
|
import stringToArrayBuffer from 'string-to-arraybuffer';
|
||||||
import URLSearchParams_Polyfill from 'url-search-params';
|
import URLSearchParams_Polyfill from 'url-search-params';
|
||||||
import { URL } from 'whatwg-url';
|
import { URL } from 'whatwg-url';
|
||||||
|
import { AbortController } from 'abortcontroller-polyfill/dist/abortcontroller';
|
||||||
|
import AbortController2 from 'abort-controller';
|
||||||
|
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
@ -53,6 +55,8 @@ const supportToString = ({
|
||||||
[Symbol.toStringTag]: 'z'
|
[Symbol.toStringTag]: 'z'
|
||||||
}).toString() === '[object z]';
|
}).toString() === '[object z]';
|
||||||
|
|
||||||
|
const supportStreamDestroy = 'destroy' in stream.Readable.prototype;
|
||||||
|
|
||||||
const local = new TestServer();
|
const local = new TestServer();
|
||||||
const base = `http://${local.hostname}:${local.port}/`;
|
const base = `http://${local.hostname}:${local.port}/`;
|
||||||
|
|
||||||
|
@ -793,6 +797,247 @@ describe('node-fetch', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support request cancellation with signal', function () {
|
||||||
|
this.timeout(500);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const controller2 = new AbortController2();
|
||||||
|
|
||||||
|
const fetches = [
|
||||||
|
fetch(`${base}timeout`, { signal: controller.signal }),
|
||||||
|
fetch(`${base}timeout`, { signal: controller2.signal }),
|
||||||
|
fetch(
|
||||||
|
`${base}timeout`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
body: JSON.stringify({ hello: 'world' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
];
|
||||||
|
setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
controller2.abort();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return Promise.all(fetches.map(fetched => expect(fetched)
|
||||||
|
.to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceOf(Error)
|
||||||
|
.and.include({
|
||||||
|
type: 'aborted',
|
||||||
|
name: 'AbortError',
|
||||||
|
})
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject immediately if signal has already been aborted', function () {
|
||||||
|
const url = `${base}timeout`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const opts = {
|
||||||
|
signal: controller.signal
|
||||||
|
};
|
||||||
|
controller.abort();
|
||||||
|
const fetched = fetch(url, opts);
|
||||||
|
return expect(fetched).to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceOf(Error)
|
||||||
|
.and.include({
|
||||||
|
type: 'aborted',
|
||||||
|
name: 'AbortError',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear internal timeout when request is cancelled with an AbortSignal', function(done) {
|
||||||
|
this.timeout(2000);
|
||||||
|
const script = `
|
||||||
|
var AbortController = require('abortcontroller-polyfill/dist/cjs-ponyfill').AbortController;
|
||||||
|
var controller = new AbortController();
|
||||||
|
require('./')(
|
||||||
|
'${base}timeout',
|
||||||
|
{ signal: controller.signal, timeout: 10000 }
|
||||||
|
);
|
||||||
|
setTimeout(function () { controller.abort(); }, 100);
|
||||||
|
`
|
||||||
|
spawn('node', ['-e', script])
|
||||||
|
.on('exit', () => {
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove internal AbortSignal event listener after request is aborted', function () {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { signal } = controller;
|
||||||
|
const promise = fetch(
|
||||||
|
`${base}timeout`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
const result = expect(promise).to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceof(Error)
|
||||||
|
.and.have.property('name', 'AbortError')
|
||||||
|
.then(() => {
|
||||||
|
expect(signal.listeners.abort.length).to.equal(0);
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow redirects to be aborted', function() {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const request = new Request(`${base}redirect/slow`, {
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, 50);
|
||||||
|
return expect(fetch(request)).to.be.eventually.rejected
|
||||||
|
.and.be.an.instanceOf(Error)
|
||||||
|
.and.have.property('name', 'AbortError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow redirected response body to be aborted', function() {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const request = new Request(`${base}redirect/slow-stream`, {
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
return expect(fetch(request).then(res => {
|
||||||
|
expect(res.headers.get('content-type')).to.equal('text/plain');
|
||||||
|
const result = res.text();
|
||||||
|
abortController.abort();
|
||||||
|
return result;
|
||||||
|
})).to.be.eventually.rejected
|
||||||
|
.and.be.an.instanceOf(Error)
|
||||||
|
.and.have.property('name', 'AbortError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove internal AbortSignal event listener after request and response complete without aborting', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { signal } = controller;
|
||||||
|
const fetchHtml = fetch(`${base}html`, { signal })
|
||||||
|
.then(res => res.text());
|
||||||
|
const fetchResponseError = fetch(`${base}error/reset`, { signal });
|
||||||
|
const fetchRedirect = fetch(`${base}redirect/301`, { signal }).then(res => res.json());
|
||||||
|
return Promise.all([
|
||||||
|
expect(fetchHtml).to.eventually.be.fulfilled.and.equal('<html></html>'),
|
||||||
|
expect(fetchResponseError).to.be.eventually.rejected,
|
||||||
|
expect(fetchRedirect).to.eventually.be.fulfilled,
|
||||||
|
]).then(() => {
|
||||||
|
expect(signal.listeners.abort.length).to.equal(0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject response body with AbortError when aborted before stream has been read completely', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
return expect(fetch(
|
||||||
|
`${base}slow`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
))
|
||||||
|
.to.eventually.be.fulfilled
|
||||||
|
.then((res) => {
|
||||||
|
const promise = res.text();
|
||||||
|
controller.abort();
|
||||||
|
return expect(promise)
|
||||||
|
.to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceof(Error)
|
||||||
|
.and.have.property('name', 'AbortError');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
return expect(fetch(
|
||||||
|
`${base}slow`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
))
|
||||||
|
.to.eventually.be.fulfilled
|
||||||
|
.then((res) => {
|
||||||
|
controller.abort();
|
||||||
|
return expect(res.text())
|
||||||
|
.to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceof(Error)
|
||||||
|
.and.have.property('name', 'AbortError');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', (done) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
expect(fetch(
|
||||||
|
`${base}slow`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
))
|
||||||
|
.to.eventually.be.fulfilled
|
||||||
|
.then((res) => {
|
||||||
|
res.body.on('error', (err) => {
|
||||||
|
expect(err)
|
||||||
|
.to.be.an.instanceof(Error)
|
||||||
|
.and.have.property('name', 'AbortError');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(supportStreamDestroy ? it : it.skip)('should cancel request body of type Stream with AbortError when aborted', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const body = new stream.Readable({ objectMode: true });
|
||||||
|
body._read = () => {};
|
||||||
|
const promise = fetch(
|
||||||
|
`${base}slow`,
|
||||||
|
{ signal: controller.signal, body, method: 'POST' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = Promise.all([
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
body.on('error', (error) => {
|
||||||
|
try {
|
||||||
|
expect(error).to.be.an.instanceof(Error).and.have.property('name', 'AbortError')
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
expect(promise).to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceof(Error)
|
||||||
|
.and.have.property('name', 'AbortError')
|
||||||
|
]);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
(supportStreamDestroy ? it.skip : it)('should immediately reject when attempting to cancel streamed Requests in node < 8', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const body = new stream.Readable({ objectMode: true });
|
||||||
|
body._read = () => {};
|
||||||
|
const promise = fetch(
|
||||||
|
`${base}slow`,
|
||||||
|
{ signal: controller.signal, body, method: 'POST' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return expect(promise).to.eventually.be.rejected
|
||||||
|
.and.be.an.instanceof(Error)
|
||||||
|
.and.have.property('message').includes('not supported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a TypeError if a signal is not of type AbortSignal', () => {
|
||||||
|
return Promise.all([
|
||||||
|
expect(fetch(`${base}inspect`, { signal: {} }))
|
||||||
|
.to.be.eventually.rejected
|
||||||
|
.and.be.an.instanceof(TypeError)
|
||||||
|
.and.have.property('message').includes('AbortSignal'),
|
||||||
|
expect(fetch(`${base}inspect`, { signal: '' }))
|
||||||
|
.to.be.eventually.rejected
|
||||||
|
.and.be.an.instanceof(TypeError)
|
||||||
|
.and.have.property('message').includes('AbortSignal'),
|
||||||
|
expect(fetch(`${base}inspect`, { signal: Object.create(null) }))
|
||||||
|
.to.be.eventually.rejected
|
||||||
|
.and.be.an.instanceof(TypeError)
|
||||||
|
.and.have.property('message').includes('AbortSignal'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set default User-Agent', function () {
|
it('should set default User-Agent', function () {
|
||||||
const url = `${base}inspect`;
|
const url = `${base}inspect`;
|
||||||
return fetch(url).then(res => res.json()).then(res => {
|
return fetch(url).then(res => res.json()).then(res => {
|
||||||
|
@ -2016,12 +2261,12 @@ describe('Request', function () {
|
||||||
}
|
}
|
||||||
for (const toCheck of [
|
for (const toCheck of [
|
||||||
'body', 'bodyUsed', 'arrayBuffer', 'blob', 'json', 'text',
|
'body', 'bodyUsed', 'arrayBuffer', 'blob', 'json', 'text',
|
||||||
'method', 'url', 'headers', 'redirect', 'clone'
|
'method', 'url', 'headers', 'redirect', 'clone', 'signal',
|
||||||
]) {
|
]) {
|
||||||
expect(enumerableProperties).to.contain(toCheck);
|
expect(enumerableProperties).to.contain(toCheck);
|
||||||
}
|
}
|
||||||
for (const toCheck of [
|
for (const toCheck of [
|
||||||
'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect'
|
'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect', 'signal',
|
||||||
]) {
|
]) {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
req[toCheck] = 'abc';
|
req[toCheck] = 'abc';
|
||||||
|
@ -2034,11 +2279,13 @@ describe('Request', function () {
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('a', '1');
|
form.append('a', '1');
|
||||||
|
const { signal } = new AbortController();
|
||||||
|
|
||||||
const r1 = new Request(url, {
|
const r1 = new Request(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
follow: 1,
|
follow: 1,
|
||||||
body: form
|
body: form,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
const r2 = new Request(r1, {
|
const r2 = new Request(r1, {
|
||||||
follow: 2
|
follow: 2
|
||||||
|
@ -2046,6 +2293,7 @@ describe('Request', function () {
|
||||||
|
|
||||||
expect(r2.url).to.equal(url);
|
expect(r2.url).to.equal(url);
|
||||||
expect(r2.method).to.equal('POST');
|
expect(r2.method).to.equal('POST');
|
||||||
|
expect(r2.signal).to.equal(signal);
|
||||||
// note that we didn't clone the body
|
// note that we didn't clone the body
|
||||||
expect(r2.body).to.equal(form);
|
expect(r2.body).to.equal(form);
|
||||||
expect(r1.follow).to.equal(1);
|
expect(r1.follow).to.equal(1);
|
||||||
|
@ -2054,6 +2302,31 @@ describe('Request', function () {
|
||||||
expect(r2.counter).to.equal(0);
|
expect(r2.counter).to.equal(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should override signal on derived Request instances', function() {
|
||||||
|
const parentAbortController = new AbortController();
|
||||||
|
const derivedAbortController = new AbortController();
|
||||||
|
const parentRequest = new Request(`test`, {
|
||||||
|
signal: parentAbortController.signal
|
||||||
|
});
|
||||||
|
const derivedRequest = new Request(parentRequest, {
|
||||||
|
signal: derivedAbortController.signal
|
||||||
|
});
|
||||||
|
expect(parentRequest.signal).to.equal(parentAbortController.signal);
|
||||||
|
expect(derivedRequest.signal).to.equal(derivedAbortController.signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow removing signal on derived Request instances', function() {
|
||||||
|
const parentAbortController = new AbortController();
|
||||||
|
const parentRequest = new Request(`test`, {
|
||||||
|
signal: parentAbortController.signal
|
||||||
|
});
|
||||||
|
const derivedRequest = new Request(parentRequest, {
|
||||||
|
signal: null
|
||||||
|
});
|
||||||
|
expect(parentRequest.signal).to.equal(parentAbortController.signal);
|
||||||
|
expect(derivedRequest.signal).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw error with GET/HEAD requests with body', function() {
|
it('should throw error with GET/HEAD requests with body', function() {
|
||||||
expect(() => new Request('.', { body: '' }))
|
expect(() => new Request('.', { body: '' }))
|
||||||
.to.throw(TypeError);
|
.to.throw(TypeError);
|
||||||
|
@ -2161,6 +2434,7 @@ describe('Request', function () {
|
||||||
let body = resumer().queue('a=1').end();
|
let body = resumer().queue('a=1').end();
|
||||||
body = body.pipe(new stream.PassThrough());
|
body = body.pipe(new stream.PassThrough());
|
||||||
const agent = new http.Agent();
|
const agent = new http.Agent();
|
||||||
|
const { signal } = new AbortController();
|
||||||
const req = new Request(url, {
|
const req = new Request(url, {
|
||||||
body,
|
body,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -2170,7 +2444,8 @@ describe('Request', function () {
|
||||||
},
|
},
|
||||||
follow: 3,
|
follow: 3,
|
||||||
compress: false,
|
compress: false,
|
||||||
agent
|
agent,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
const cl = req.clone();
|
const cl = req.clone();
|
||||||
expect(cl.url).to.equal(url);
|
expect(cl.url).to.equal(url);
|
||||||
|
@ -2182,6 +2457,7 @@ describe('Request', function () {
|
||||||
expect(cl.method).to.equal('POST');
|
expect(cl.method).to.equal('POST');
|
||||||
expect(cl.counter).to.equal(0);
|
expect(cl.counter).to.equal(0);
|
||||||
expect(cl.agent).to.equal(agent);
|
expect(cl.agent).to.equal(agent);
|
||||||
|
expect(cl.signal).to.equal(signal);
|
||||||
// clone body shouldn't be the same body
|
// clone body shouldn't be the same body
|
||||||
expect(cl.body).to.not.equal(body);
|
expect(cl.body).to.not.equal(body);
|
||||||
return Promise.all([cl.text(), req.text()]).then(results => {
|
return Promise.all([cl.text(), req.text()]).then(results => {
|
||||||
|
|
Loading…
Reference in New Issue