diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index b9e26f09752..6e21751ecab 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -29,7 +29,8 @@ Returns: `Client` * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. -* **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. + +* **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. **Note: this is deprecated in favor of [Dispatcher#compose](./Dispatcher.md#dispatcher). Support will be droped in next major.** * **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. * **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. * **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 5b18be7a84c..88c3f11e7ce 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -817,6 +817,141 @@ try { } ``` +### `Dispatcher.compose(interceptors[, interceptor])` + +Compose a new dispatcher from the current dispatcher and the given interceptors. + +> _Notes_: +> - The order of the interceptors matters. The first interceptor will be the first to be called. +> - It is important to note that the `interceptor` function should return a function that follows the `Dispatcher.dispatch` signature. +> - Any fork of the chain of `interceptors` can lead to unexpected results. + +Arguments: + +* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. + +Returns: `Dispatcher`. + +#### Parameter: `Interceptor` + +A function that takes a `dispatch` method and returns a `dispatch`-like function. + +#### Example 1 - Basic Compose + +```js +const { Client, RedirectHandler } = require('undici') + +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } +} + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + +await client.request({ path: '/', method: 'GET' }) +``` + +#### Example 2 - Chained Compose + +```js +const { Client, RedirectHandler, RetryHandler } = require('undici') + +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } + return dispatch(opts, redirectHandler) + } +} + +const retryInterceptor = dispatch => { + return function retryInterceptor (opts, handler) { + return dispatch( + opts, + new RetryHandler(opts, { + handler, + dispatch + }) + ) + } +} + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + .compose(retryInterceptor) + +await client.request({ path: '/', method: 'GET' }) +``` + +#### Pre-built interceptors + +##### `redirect` + +The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. + +It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { redirect } = interceptors; + +const client = new Client("http://example.com").compose( + redirect({ maxRedirections: 3, throwOnMaxRedirects: true }) +); +client.request({ path: "/" }) +``` + +##### `retry` + +The `retry` interceptor allows you to customize the way your dispatcher handles retries. + +It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { retry } = interceptors; + +const client = new Client("http://example.com").compose( + retry({ + maxRetries: 3, + minTimeout: 1000, + maxTimeout: 10000, + timeoutFactor: 2, + retryAfter: true, + }) +); +``` + ## Instance Events ### Event: `'connect'` diff --git a/index.js b/index.js index f72ea8a540e..dcf0be90798 100644 --- a/index.js +++ b/index.js @@ -36,6 +36,10 @@ module.exports.RetryHandler = RetryHandler module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor +module.exports.interceptors = { + redirect: require('./lib/interceptor/redirect'), + retry: require('./lib/interceptor/retry') +} module.exports.buildConnector = buildConnector module.exports.errors = errors diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index d90ed6ad914..60e68135549 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -59,6 +59,7 @@ const { } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') const connectH2 = require('./client-h2.js') +let deprecatedInterceptorWarned = false const kClosedResolve = Symbol('kClosedResolve') @@ -207,9 +208,18 @@ class Client extends DispatcherBase { }) } - this[kInterceptors] = interceptors?.Client && Array.isArray(interceptors.Client) - ? interceptors.Client - : [createRedirectInterceptor({ maxRedirections })] + if (interceptors?.Client && Array.isArray(interceptors.Client)) { + this[kInterceptors] = interceptors.Client + if (!deprecatedInterceptorWarned) { + deprecatedInterceptorWarned = true + process.emitWarning('Client.Options#interceptor is deprecated. Use Dispatcher#compose instead.', { + code: 'UNDICI-CLIENT-INTERCEPTOR-DEPRECATED' + }) + } + } else { + this[kInterceptors] = [createRedirectInterceptor({ maxRedirections })] + } + this[kUrl] = util.parseOrigin(url) this[kConnector] = connect this[kPipelining] = pipelining != null ? pipelining : 1 diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index 71db7e2bb49..b1e0098ec4b 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -1,5 +1,4 @@ 'use strict' - const EventEmitter = require('node:events') class Dispatcher extends EventEmitter { @@ -14,6 +13,53 @@ class Dispatcher extends EventEmitter { destroy () { throw new Error('not implemented') } + + compose (...args) { + // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ... + const interceptors = Array.isArray(args[0]) ? args[0] : args + let dispatch = this.dispatch.bind(this) + + for (const interceptor of interceptors) { + if (interceptor == null) { + continue + } + + if (typeof interceptor !== 'function') { + throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`) + } + + dispatch = interceptor(dispatch) + + if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) { + throw new TypeError('invalid interceptor') + } + } + + return new ComposedDispatcher(this, dispatch) + } +} + +class ComposedDispatcher extends Dispatcher { + #dispatcher = null + #dispatch = null + + constructor (dispatcher, dispatch) { + super() + this.#dispatcher = dispatcher + this.#dispatch = dispatch + } + + dispatch (...args) { + this.#dispatch(...args) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } } module.exports = Dispatcher diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js new file mode 100644 index 00000000000..d2e789d8efb --- /dev/null +++ b/lib/interceptor/redirect.js @@ -0,0 +1,24 @@ +'use strict' +const RedirectHandler = require('../handler/redirect-handler') + +module.exports = opts => { + const globalMaxRedirections = opts?.maxRedirections + return dispatch => { + return function redirectInterceptor (opts, handler) { + const { maxRedirections = globalMaxRedirections, ...baseOpts } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + + return dispatch(baseOpts, redirectHandler) + } + } +} diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js new file mode 100644 index 00000000000..1c16fd845a9 --- /dev/null +++ b/lib/interceptor/retry.js @@ -0,0 +1,19 @@ +'use strict' +const RetryHandler = require('../handler/retry-handler') + +module.exports = globalOpts => { + return dispatch => { + return function retryInterceptor (opts, handler) { + return dispatch( + opts, + new RetryHandler( + { ...opts, retryOptions: { ...globalOpts, ...opts.retryOptions } }, + { + handler, + dispatch + } + ) + ) + } + } +} diff --git a/test/dispatcher.js b/test/dispatcher.js index 95a9bc59a83..1febdf53b4b 100644 --- a/test/dispatcher.js +++ b/test/dispatcher.js @@ -20,3 +20,23 @@ test('dispatcher implementation', (t) => { t.throws(() => poorImplementation.close(), Error, 'throws on unimplemented close') t.throws(() => poorImplementation.destroy(), Error, 'throws on unimplemented destroy') }) + +test('dispatcher.compose', (t) => { + t = tspl(t, { plan: 10 }) + + const dispatcher = new Dispatcher() + const interceptor = () => (opts, handler) => {} + // Should return a new dispatcher + t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor)) instanceof Dispatcher) + t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor, interceptor)) instanceof Dispatcher) + t.ok(Object.getPrototypeOf(dispatcher.compose([interceptor, interceptor])) instanceof Dispatcher) + t.ok(dispatcher.compose(interceptor) !== dispatcher) + t.throws(() => dispatcher.dispatch({}), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(() => null), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(dispatch => dispatch, () => () => {}, Error, 'invalid interceptor')) + + const composed = dispatcher.compose(interceptor) + t.equal(typeof composed.dispatch, 'function', 'returns an object with a dispatch method') + t.equal(typeof composed.close, 'function', 'returns an object with a close method') + t.equal(typeof composed.destroy, 'function', 'returns an object with a destroy method') +}) diff --git a/test/interceptors/redirect.js b/test/interceptors/redirect.js new file mode 100644 index 00000000000..239c9597568 --- /dev/null +++ b/test/interceptors/redirect.js @@ -0,0 +1,672 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const undici = require('../..') +const { + startRedirectingServer, + startRedirectingWithBodyServer, + startRedirectingChainServers, + startRedirectingWithoutLocationServer, + startRedirectingWithAuthorization, + startRedirectingWithCookie, + startRedirectingWithQueryParams +} = require('../utils/redirecting-servers') +const { createReadable, createReadableStream } = require('../utils/stream') + +const { + interceptors: { redirect } +} = undici + +for (const factory of [ + (server, opts) => + new undici.Agent(opts).compose(redirect(opts?.maxRedirections)), + (server, opts) => + new undici.Pool(`http://${server}`, opts).compose( + redirect(opts?.maxRedirections) + ), + (server, opts) => + new undici.Client(`http://${server}`, opts).compose( + redirect(opts?.maxRedirections) + ) +]) { + const request = (t, server, opts, ...args) => { + const dispatcher = factory(server, opts) + after(() => dispatcher.close()) + return undici.request(args[0], { ...args[1], dispatcher }, args[2]) + } + + test('should always have a history with the final URL even if no redirections were followed', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/200?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/200?key=value`] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should not follow redirection by default if not using RedirectAgent', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}`) + const body = await bodyStream.text() + + t.strictEqual(statusCode, 302) + t.strictEqual(headers.location, `http://${server}/302/1`) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should follow redirection after a HTTP 300', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/300?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 300 default', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request( + t, + server, + { maxRedirections: 10 }, + `http://${server}/300?key=value` + ) + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 301', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `POST /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST` + ) + }) + + test('should follow redirection after a HTTP 302', async t => { + t = tspl(t, { plan: 3 }) + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/302`, { + method: 'PUT', + body: Buffer.from('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `PUT /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST` + ) + }) + + test('should follow redirection after a HTTP 303 changing method to GET', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `GET /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should remove Host and request body related headers when following HTTP 303 (array)', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: [ + 'Content-Encoding', + 'gzip', + 'X-Foo1', + '1', + 'X-Foo2', + '2', + 'Content-Type', + 'application/json', + 'X-Foo3', + '3', + 'Host', + 'localhost', + 'X-Bar', + '4' + ], + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4` + ) + + await t.completed + }) + + test('should remove Host and request body related headers when following HTTP 303 (object)', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: { + 'Content-Encoding': 'gzip', + 'X-Foo1': '1', + 'X-Foo2': '2', + 'Content-Type': 'application/json', + 'X-Foo3': '3', + Host: 'localhost', + 'X-Bar': '4' + }, + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 307', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/307`, { + method: 'DELETE', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `DELETE /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should follow redirection after a HTTP 308', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/308`, { + method: 'OPTIONS', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `OPTIONS /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should ignore HTTP 3xx response bodies', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingWithBodyServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/`, `http://${server}/end`] + ) + t.strictEqual(body, 'FINAL') + + await t.completed + }) + + test('should ignore query after redirection', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingWithQueryParams() + + const { + statusCode, + headers, + context: { history } + } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10, + query: { param1: 'first' } + }) + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/`, `http://${server}/?param2=second`] + ) + + await t.completed + }) + + test('should follow a redirect chain up to the allowed number of times', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/300`, { + maxRedirections: 2 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 300) + t.strictEqual(headers.location, `http://${server}/300/3`) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300`, + `http://${server}/300/1`, + `http://${server}/300/2` + ] + ) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should follow a redirect chain up to the allowed number of times for redirectionLimitReached', async t => { + t = tspl(t, { plan: 1 }) + + const server = await startRedirectingServer() + + try { + await request(t, server, undefined, `http://${server}/300`, { + maxRedirections: 2, + throwOnMaxRedirect: true + }) + } catch (error) { + if (error.message.startsWith('max redirects')) { + t.ok(true, 'Max redirects handled correctly') + } else { + t.fail(`Unexpected error: ${error.message}`) + } + } + + await t.completed + }) + + test('when a Location response header is NOT present', async t => { + t = tspl(t, { plan: 6 * 3 }) + + const redirectCodes = [300, 301, 302, 303, 307, 308] + const server = await startRedirectingWithoutLocationServer() + + for (const code of redirectCodes) { + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/${code}`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, code) + t.ok(!headers.location) + t.strictEqual(body.length, 0) + } + await t.completed + }) + + test('should not allow invalid maxRedirections arguments', async t => { + t = tspl(t, { plan: 1 }) + + try { + await request(t, 'localhost', undefined, 'http://localhost', { + method: 'GET', + maxRedirections: 'INVALID' + }) + + t.fail('Did not throw') + } catch (err) { + t.strictEqual(err.message, 'maxRedirections must be a positive number') + } + await t.completed + }) + + test('should not allow invalid maxRedirections arguments default', async t => { + t = tspl(t, { plan: 1 }) + + try { + await request( + t, + 'localhost', + { + maxRedirections: 'INVALID' + }, + 'http://localhost', + { + method: 'GET' + } + ) + + t.fail('Did not throw') + } catch (err) { + t.strictEqual(err.message, 'maxRedirections must be a positive number') + } + + await t.completed + }) + + test('should not follow redirects when using ReadableStream request bodies', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadableStream('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 301) + t.strictEqual(headers.location, `http://${server}/301/2`) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should not follow redirects when using Readable request bodies', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadable('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 301) + t.strictEqual(headers.location, `http://${server}/301/1`) + t.strictEqual(body.length, 0) + await t.completed + }) +} + +test('should follow redirections when going cross origin', async t => { + t = tspl(t, { plan: 4 }) + + const [server1, server2, server3] = await startRedirectingChainServers() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await undici.request(`http://${server1}`, { + method: 'POST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server1}/`, + `http://${server2}/`, + `http://${server3}/`, + `http://${server2}/end`, + `http://${server3}/end`, + `http://${server1}/end` + ] + ) + t.strictEqual(body, 'POST') + + await t.completed +}) + +test('should handle errors (callback)', async t => { + t = tspl(t, { plan: 1 }) + + undici.request( + 'http://localhost:0', + { + maxRedirections: 10 + }, + error => { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } + ) + + await t.completed +}) + +test('should handle errors (promise)', async t => { + t = tspl(t, { plan: 1 }) + + try { + await undici.request('http://localhost:0', { maxRedirections: 10 }) + t.fail('Did not throw') + } catch (error) { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } + + await t.completed +}) + +test('removes authorization header on third party origin', async t => { + t = tspl(t, { plan: 1 }) + + const [server1] = await startRedirectingWithAuthorization('secret') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + authorization: 'secret' + } + }) + + const body = await bodyStream.text() + + t.strictEqual(body, '') + + await t.completed +}) + +test('removes cookie header on third party origin', async t => { + t = tspl(t, { plan: 1 }) + const [server1] = await startRedirectingWithCookie('a=b') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + cookie: 'a=b' + } + }) + + const body = await bodyStream.text() + + t.strictEqual(body, '') + + await t.completed +}) diff --git a/test/interceptors/retry.js b/test/interceptors/retry.js new file mode 100644 index 00000000000..6e6f997cafa --- /dev/null +++ b/test/interceptors/retry.js @@ -0,0 +1,482 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { createServer } = require('node:http') +const { once } = require('node:events') + +const { Client, interceptors } = require('../..') +const { retry } = interceptors + +test('Should retry status code', async t => { + t = tspl(t, { plan: 4 }) + + let counter = 0 + const server = createServer() + const retryOptions = { + retry: (err, { state, opts }, done) => { + counter++ + + if (err.statusCode === 500 || err.message.includes('other side closed')) { + setTimeout(done, 500) + return + } + + return done(err) + } + } + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + t.ok(true, 'pass') + return + case 1: + res.writeHead(500) + res.end('failed') + t.ok(true, 'pass') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry(retryOptions)) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should use retry-after header for retries', async t => { + t = tspl(t, { plan: 3 }) + + let counter = 0 + const server = createServer() + let checkpoint + const dispatchOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': 1 + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 500) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(dispatchOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should use retry-after header for retries (date)', async t => { + t = tspl(t, { plan: 3 }) + + let counter = 0 + const server = createServer() + let checkpoint + const reuestOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': new Date( + new Date().setSeconds(new Date().getSeconds() + 1) + ).toUTCString() + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 1) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(reuestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should retry with defaults', async t => { + t = tspl(t, { plan: 2 }) + + let counter = 0 + const server = createServer() + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + counter++ + return + case 1: + res.writeHead(500) + res.end('failed') + counter++ + return + case 2: + res.writeHead(200) + res.end('hello world!') + counter++ + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should handle 206 partial content', async t => { + t = tspl(t, { plan: 5 }) + + let counter = 0 + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'asd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const retryOptions = { + retry: function (err, _, done) { + counter++ + + if (err.code && err.code === 'UND_ERR_DESTROYED') { + return done(false) + } + + if (err.statusCode === 206) return done(err) + + setTimeout(done, 800) + } + } + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + }, + retryOptions + } + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.strictEqual(await response.body.text(), 'abcdef') + t.strictEqual(counter, 1) +}) + +test('Should handle 206 partial content - bad-etag', async t => { + t = tspl(t, { plan: 3 }) + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'erwsd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + }, + retryOptions: { + retry: (err, { state, opts }, done) => { + if (err.message.includes('other side closed')) { + setTimeout(done, 100) + return + } + + return done(err) + } + } + } + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + try { + const response = await client.request(requestOptions) + await response.body.text() + } catch (error) { + t.strict(error, { + message: 'ETag mismatch', + code: 'UND_ERR_REQ_RETRY', + name: 'RequestRetryError' + }) + } +}) + +test('retrying a request with a body', async t => { + t = tspl(t, { plan: 2 }) + let counter = 0 + const server = createServer() + const requestOptions = { + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ hello: 'world' }), + retryOptions: { + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + return + case 1: + res.writeHead(500) + res.end('failed') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('should not error if request is not meant to be retried', async t => { + t = tspl(t, { plan: 2 }) + + const server = createServer() + server.on('request', (req, res) => { + res.writeHead(400) + res.end('Bad request') + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request({ + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }) + + t.equal(response.statusCode, 400) + t.equal(await response.body.text(), 'Bad request') +})