diff --git a/documentation/2-options.md b/documentation/2-options.md index 8969a2a11..ab7b24c49 100644 --- a/documentation/2-options.md +++ b/documentation/2-options.md @@ -209,6 +209,28 @@ await got('https://httpbin.org/anything'); #### **Note:** > - If you're passing an absolute URL as `url`, you need to set `prefixUrl` to an empty string. +### `signal` + +**Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)** + +You can abort the `request` using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + +*Requires Node.js 16 or later.* + +```js +import got from 'got'; + +const abortController = new AbortController(); + +const request = got('https://httpbin.org/anything', { + signal: abortController.signal +}); + +setTimeout(() => { + abortController.abort(); +}, 100); +``` + ### `method` **Type: `string`**\ diff --git a/documentation/8-errors.md b/documentation/8-errors.md index e32f44283..2cb97aa7d 100644 --- a/documentation/8-errors.md +++ b/documentation/8-errors.md @@ -97,3 +97,9 @@ When the request is aborted with `promise.cancel()`. **Code: `ERR_RETRYING`** Always triggers a new retry when thrown. + +### `AbortError` + +**Code: `ERR_ABORTED`** + +When the request is aborted with [AbortController.abort()](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort). diff --git a/source/core/errors.ts b/source/core/errors.ts index fc48166f2..d344517da 100644 --- a/source/core/errors.ts +++ b/source/core/errors.ts @@ -170,3 +170,14 @@ export class RetryError extends RequestError { this.code = 'ERR_RETRYING'; } } + +/** +An error to be thrown when the request is aborted by AbortController. +*/ +export class AbortError extends RequestError { + constructor(request: Request) { + super('This operation was aborted.', {}, request); + this.code = 'ERR_ABORTED'; + this.name = 'AbortError'; + } +} diff --git a/source/core/index.ts b/source/core/index.ts index ccba907ad..01a12128e 100644 --- a/source/core/index.ts +++ b/source/core/index.ts @@ -32,6 +32,7 @@ import { TimeoutError, UploadError, CacheError, + AbortError, } from './errors.js'; import type {PlainResponse} from './response.js'; import type {PromiseCookieJar, NativeRequestOptions, RetryOptions} from './options.js'; @@ -241,6 +242,14 @@ export default class Request extends Duplex implements RequestEvents { return; } + if (this.options.signal?.aborted) { + this.destroy(new AbortError(this)); + } + + this.options.signal?.addEventListener('abort', () => { + this.destroy(new AbortError(this)); + }); + // Important! If you replace `body` in a handler with another stream, make sure it's readable first. // The below is run only once. const {body} = this.options; diff --git a/source/core/options.ts b/source/core/options.ts index 105f2b7a8..38191f38e 100644 --- a/source/core/options.ts +++ b/source/core/options.ts @@ -827,6 +827,7 @@ const defaultInternals: Options['_internals'] = { }, setHost: true, maxHeaderSize: undefined, + signal: undefined, enableUnixSockets: true, }; @@ -1489,6 +1490,38 @@ export default class Options { } } + /** + You can abort the `request` using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + + *Requires Node.js 16 or later.* + + @example + ``` + import got from 'got'; + + const abortController = new AbortController(); + + const request = got('https://httpbin.org/anything', { + signal: abortController.signal + }); + + setTimeout(() => { + abortController.abort(); + }, 100); + ``` + */ + // TODO: Replace `any` with `AbortSignal` when targeting Node 16. + get signal(): any | undefined { + return this._internals.signal; + } + + // TODO: Replace `any` with `AbortSignal` when targeting Node 16. + set signal(value: any | undefined) { + assert.object(value); + + this._internals.signal = value; + } + /** Ignore invalid cookies instead of throwing an error. Only useful when the `cookieJar` option has been set. Not recommended. @@ -2488,5 +2521,6 @@ export default class Options { Object.freeze(options.retry.methods); Object.freeze(options.retry.statusCodes); Object.freeze(options.context); + Object.freeze(options.signal); } } diff --git a/test/abort.ts b/test/abort.ts new file mode 100644 index 000000000..3312b090f --- /dev/null +++ b/test/abort.ts @@ -0,0 +1,274 @@ +import process from 'process'; +import {EventEmitter} from 'events'; +import stream, {Readable as ReadableStream} from 'stream'; +import test from 'ava'; +import delay from 'delay'; +import {pEvent} from 'p-event'; +import {Handler} from 'express'; +import got from '../source/index.js'; +import slowDataStream from './helpers/slow-data-stream.js'; +import {GlobalClock} from './helpers/types.js'; +import {ExtendedHttpTestServer} from './helpers/create-http-test-server.js'; +import withServer, {withServerAndFakeTimers} from './helpers/with-server.js'; + +if (globalThis.AbortController !== undefined) { + const prepareServer = (server: ExtendedHttpTestServer, clock: GlobalClock): {emitter: EventEmitter; promise: Promise} => { + const emitter = new EventEmitter(); + + const promise = new Promise((resolve, reject) => { + server.all('/abort', async (request, response) => { + emitter.emit('connection'); + + request.once('aborted', resolve); + response.once('finish', reject.bind(null, new Error('Request finished instead of aborting.'))); + + try { + await pEvent(request, 'end'); + } catch { + // Node.js 15.0.0 throws AND emits `aborted` + } + + response.end(); + }); + + server.get('/redirect', (_request, response) => { + response.writeHead(302, { + location: `${server.url}/abort`, + }); + response.end(); + + emitter.emit('sentRedirect'); + + clock.tick(3000); + resolve(); + }); + }); + + return {emitter, promise}; + }; + + const downloadHandler = (clock?: GlobalClock): Handler => (_request, response) => { + response.writeHead(200, { + 'transfer-encoding': 'chunked', + }); + + response.flushHeaders(); + + stream.pipeline( + slowDataStream(clock), + response, + () => { + response.end(); + }, + ); + }; + + test.serial('does not retry after abort', withServerAndFakeTimers, async (t, server, got, clock) => { + const {emitter, promise} = prepareServer(server, clock); + const controller = new AbortController(); + + const gotPromise = got('redirect', { + signal: controller.signal, + retry: { + calculateDelay() { + t.fail('Makes a new try after abort'); + return 0; + }, + }, + }); + + emitter.once('sentRedirect', () => { + controller.abort(); + }); + + await t.throwsAsync(gotPromise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + + await t.notThrowsAsync(promise, 'Request finished instead of aborting.'); + }); + + test.serial('abort request timeouts', withServer, async (t, server, got) => { + server.get('/', () => {}); + + const controller = new AbortController(); + + const gotPromise = got({ + signal: controller.signal, + timeout: { + request: 10, + }, + retry: { + calculateDelay({computedValue}) { + process.nextTick(() => { + controller.abort(); + }); + + if (computedValue) { + return 20; + } + + return 0; + }, + limit: 1, + }, + }); + + await t.throwsAsync(gotPromise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + + // Wait for unhandled errors + await delay(40); + }); + + test.serial('aborts in-progress request', withServerAndFakeTimers, async (t, server, got, clock) => { + const {emitter, promise} = prepareServer(server, clock); + + const controller = new AbortController(); + + const body = new ReadableStream({ + read() {}, + }); + body.push('1'); + + const gotPromise = got.post('abort', {body, signal: controller.signal}); + + // Wait for the connection to be established before canceling + emitter.once('connection', () => { + controller.abort(); + body.push(null); + }); + + await t.throwsAsync(gotPromise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + await t.notThrowsAsync(promise, 'Request finished instead of aborting.'); + }); + + test.serial('aborts in-progress request with timeout', withServerAndFakeTimers, async (t, server, got, clock) => { + const {emitter, promise} = prepareServer(server, clock); + + const controller = new AbortController(); + + const body = new ReadableStream({ + read() {}, + }); + body.push('1'); + + const gotPromise = got.post('abort', {body, timeout: {request: 10_000}, signal: controller.signal}); + + // Wait for the connection to be established before canceling + emitter.once('connection', () => { + controller.abort(); + body.push(null); + }); + + await t.throwsAsync(gotPromise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + await t.notThrowsAsync(promise, 'Request finished instead of aborting.'); + }); + + test.serial('abort immediately', withServerAndFakeTimers, async (t, server, got, clock) => { + const controller = new AbortController(); + + const promise = new Promise((resolve, reject) => { + // We won't get an abort or even a connection + // We assume no request within 1000ms equals a (client side) aborted request + server.get('/abort', (_request, response) => { + response.once('finish', reject.bind(global, new Error('Request finished instead of aborting.'))); + response.end(); + }); + + clock.tick(1000); + resolve(); + }); + + const gotPromise = got('abort', {signal: controller.signal}); + controller.abort(); + + await t.throwsAsync(gotPromise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + await t.notThrowsAsync(promise, 'Request finished instead of aborting.'); + }); + + test('recover from abort using abortable promise attribute', async t => { + // Abort before connection started + const controller = new AbortController(); + + const p = got('http://example.com', {signal: controller.signal}); + const recover = p.catch((error: Error) => { + if (controller.signal.aborted) { + return; + } + + throw error; + }); + + controller.abort(); + + await t.notThrowsAsync(recover); + }); + + test('recover from abort using error instance', async t => { + const controller = new AbortController(); + + const p = got('http://example.com', {signal: controller.signal}); + const recover = p.catch((error: Error) => { + if (error.message === 'This operation was aborted.') { + return; + } + + throw error; + }); + + controller.abort(); + + await t.notThrowsAsync(recover); + }); + + // TODO: Use `fakeTimers` here + test.serial('throws on incomplete (aborted) response', withServer, async (t, server, got) => { + server.get('/', downloadHandler()); + + const controller = new AbortController(); + + const promise = got('', {signal: controller.signal}); + + setTimeout(() => { + controller.abort(); + }, 400); + + await t.throwsAsync(promise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + }); + + test('throws when aborting cached request', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.setHeader('Cache-Control', 'public, max-age=60'); + response.end(Date.now().toString()); + }); + + const cache = new Map(); + + await got({cache}); + + const controller = new AbortController(); + const promise = got({cache, signal: controller.signal}); + controller.abort(); + + await t.throwsAsync(promise, { + code: 'ERR_ABORTED', + message: 'This operation was aborted.', + }); + }); +}