diff --git a/.gitignore b/.gitignore index acd1b69eca2..7ec4b6e2e93 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,5 @@ fuzz-results-*.json # Bundle output undici-fetch.js /test/imports/undici-import.js + +.tap diff --git a/index.js b/index.js index cd97055e8c1..9b8f421e853 100644 --- a/index.js +++ b/index.js @@ -14,12 +14,7 @@ const MockClient = require('./lib/mock/mock-client') const MockAgent = require('./lib/mock/mock-agent') const MockPool = require('./lib/mock/mock-pool') const mockErrors = require('./lib/mock/mock-errors') -const ProxyAgent = require('./lib/proxy-agent') -const RetryAgent = require('./lib/retry-agent') -const RetryHandler = require('./lib/handler/RetryHandler') const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global') -const DecoratorHandler = require('./lib/handler/DecoratorHandler') -const RedirectHandler = require('./lib/handler/RedirectHandler') Object.assign(Dispatcher.prototype, api) @@ -28,12 +23,12 @@ module.exports.Client = Client module.exports.Pool = Pool module.exports.BalancedPool = BalancedPool module.exports.Agent = Agent -module.exports.ProxyAgent = ProxyAgent -module.exports.RetryAgent = RetryAgent -module.exports.RetryHandler = RetryHandler -module.exports.DecoratorHandler = DecoratorHandler -module.exports.RedirectHandler = RedirectHandler +module.exports.interceptor = { + redirect: require('./lib/interceptor/redirect'), + retry: require('./lib/interceptor/retry'), + proxy: require('./lib/interceptor/proxy') +} module.exports.buildConnector = buildConnector module.exports.errors = errors diff --git a/lib/api/api-connect.js b/lib/api/api-connect.js index c5b163cb44c..136792c57d8 100644 --- a/lib/api/api-connect.js +++ b/lib/api/api-connect.js @@ -3,7 +3,7 @@ const { AsyncResource } = require('node:async_hooks') const { InvalidArgumentError, RequestAbortedError, SocketError } = require('../core/errors') const util = require('../core/util') -const RedirectHandler = require('../handler/RedirectHandler') +const redirect = require('../interceptor/redirect') const { addSignal, removeSignal } = require('./abort-signal') class ConnectHandler extends AsyncResource { @@ -91,19 +91,9 @@ function connect (opts, callback) { } try { - const connectHandler = new ConnectHandler(opts, callback) - const connectOptions = { ...opts, method: 'CONNECT' } - - if (opts?.maxRedirections != null && (!Number.isInteger(opts?.maxRedirections) || opts?.maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - if (opts?.maxRedirections > 0) { - RedirectHandler.buildDispatch(this, opts.maxRedirections)(connectOptions, connectHandler) - return - } - - this.dispatch(connectOptions, connectHandler) + this + .compose(redirect(opts)) + .dispatch({ ...opts, method: opts?.method || 'CONNECT' }, new ConnectHandler(opts, callback)) } catch (err) { if (typeof callback !== 'function') { throw err diff --git a/lib/api/api-pipeline.js b/lib/api/api-pipeline.js index 534d9a53b45..8d6a498d2fe 100644 --- a/lib/api/api-pipeline.js +++ b/lib/api/api-pipeline.js @@ -13,7 +13,7 @@ const { RequestAbortedError } = require('../core/errors') const util = require('../core/util') -const RedirectHandler = require('../handler/RedirectHandler') +const redirect = require('../interceptor/redirect') const { addSignal, removeSignal } = require('./abort-signal') const kResume = Symbol('resume') @@ -241,15 +241,9 @@ function pipeline (opts, handler) { try { const pipelineHandler = new PipelineHandler(opts, handler) - if (opts?.maxRedirections != null && (!Number.isInteger(opts?.maxRedirections) || opts?.maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - if (opts?.maxRedirections > 0) { - RedirectHandler.buildDispatch(this, opts.maxRedirections)({ ...opts, body: pipelineHandler.req }, pipelineHandler) - } else { - this.dispatch({ ...opts, body: pipelineHandler.req }, pipelineHandler) - } + this + .compose(redirect(opts)) + .dispatch({ ...opts, body: pipelineHandler.req }, pipelineHandler) return pipelineHandler.ret } catch (err) { diff --git a/lib/api/api-request.js b/lib/api/api-request.js index 8a3adc7bf95..ee623e97a5f 100644 --- a/lib/api/api-request.js +++ b/lib/api/api-request.js @@ -7,7 +7,7 @@ const { RequestAbortedError } = require('../core/errors') const util = require('../core/util') -const RedirectHandler = require('../handler/RedirectHandler') +const redirect = require('../interceptor/redirect') const { getResolveErrorBodyCallback } = require('./util') const { addSignal, removeSignal } = require('./abort-signal') @@ -167,18 +167,9 @@ function request (opts, callback) { } try { - const handler = new RequestHandler(opts, callback) - - if (opts?.maxRedirections != null && (!Number.isInteger(opts?.maxRedirections) || opts?.maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - if (opts?.maxRedirections > 0) { - RedirectHandler.buildDispatch(this, opts.maxRedirections)(opts, handler) - return - } - - this.dispatch(opts, handler) + this + .compose(redirect(opts)) + .dispatch(opts, new RequestHandler(opts, callback)) } catch (err) { if (typeof callback !== 'function') { throw err diff --git a/lib/api/api-stream.js b/lib/api/api-stream.js index 2256593f20b..6e3793fef94 100644 --- a/lib/api/api-stream.js +++ b/lib/api/api-stream.js @@ -7,7 +7,7 @@ const { RequestAbortedError } = require('../core/errors') const util = require('../core/util') -const RedirectHandler = require('../handler/RedirectHandler') +const redirect = require('../interceptor/redirect') const { getResolveErrorBodyCallback } = require('./util') const { AsyncResource } = require('node:async_hooks') const { addSignal, removeSignal } = require('./abort-signal') @@ -208,18 +208,9 @@ function stream (opts, factory, callback) { } try { - const handler = new StreamHandler(opts, factory, callback) - - if (opts?.maxRedirections != null && (!Number.isInteger(opts?.maxRedirections) || opts?.maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - if (opts?.maxRedirections > 0) { - RedirectHandler.buildDispatch(this, opts.maxRedirections)(opts, handler) - return - } - - this.dispatch(opts, handler) + this + .compose(redirect(opts)) + .dispatch(opts, new StreamHandler(opts, factory, callback)) } catch (err) { if (typeof callback !== 'function') { throw err diff --git a/lib/api/api-upgrade.js b/lib/api/api-upgrade.js index 00a2147de31..6b4ca48ca37 100644 --- a/lib/api/api-upgrade.js +++ b/lib/api/api-upgrade.js @@ -4,7 +4,7 @@ const assert = require('node:assert') const { AsyncResource } = require('node:async_hooks') const { InvalidArgumentError, RequestAbortedError, SocketError } = require('../core/errors') const util = require('../core/util') -const RedirectHandler = require('../handler/RedirectHandler') +const redirect = require('../interceptor/redirect') const { addSignal, removeSignal } = require('./abort-signal') class UpgradeHandler extends AsyncResource { @@ -88,23 +88,9 @@ function upgrade (opts, callback) { } try { - const upgradeHandler = new UpgradeHandler(opts, callback) - const upgradeOpts = { - ...opts, - method: opts.method || 'GET', - upgrade: opts.protocol || 'Websocket' - } - - if (opts?.maxRedirections != null && (!Number.isInteger(opts?.maxRedirections) || opts?.maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - if (opts?.maxRedirections > 0) { - RedirectHandler.buildDispatch(this, opts.maxRedirections)(upgradeOpts, upgradeHandler) - return - } - - this.dispatch(upgradeOpts, upgradeHandler) + this + .compose(redirect(opts)) + .dispatch({ ...opts, method: opts?.method || 'GET', upgrade: opts?.protocol || 'Websocket' }, new UpgradeHandler(opts, callback)) } catch (err) { if (typeof callback !== 'function') { throw err diff --git a/lib/dispatcher.js b/lib/dispatcher.js index 71db7e2bb49..7e0af261fee 100644 --- a/lib/dispatcher.js +++ b/lib/dispatcher.js @@ -2,7 +2,11 @@ const EventEmitter = require('node:events') +const kDispatcherVersion = Symbol.for('undici.dispatcher.version') + class Dispatcher extends EventEmitter { + [kDispatcherVersion] = 1 + dispatch () { throw new Error('not implemented') } @@ -14,6 +18,26 @@ class Dispatcher extends EventEmitter { destroy () { throw new Error('not implemented') } + + compose (...interceptors) { + let dispatcher = this + for (const interceptor of interceptors) { + if (interceptor == null) { + continue + } + + if (typeof interceptor !== 'function') { + throw new Error('invalid interceptor') + } + + dispatcher = interceptor(dispatcher) ?? dispatcher + + if (dispatcher[kDispatcherVersion] !== 1) { + throw new Error('invalid dispatcher') + } + } + return dispatcher + } } module.exports = Dispatcher diff --git a/lib/handler/DecoratorHandler.js b/lib/handler/DecoratorHandler.js deleted file mode 100644 index cbf0ddd4638..00000000000 --- a/lib/handler/DecoratorHandler.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict' - -module.exports = class DecoratorHandler { - constructor (handler) { - this.handler = handler - } - - onConnect (...args) { - return this.handler.onConnect(...args) - } - - onError (...args) { - return this.handler.onError(...args) - } - - onUpgrade (...args) { - return this.handler.onUpgrade(...args) - } - - onHeaders (...args) { - return this.handler.onHeaders(...args) - } - - onData (...args) { - return this.handler.onData(...args) - } - - onComplete (...args) { - return this.handler.onComplete(...args) - } -} diff --git a/lib/proxy-agent.js b/lib/interceptor/proxy.js similarity index 85% rename from lib/proxy-agent.js rename to lib/interceptor/proxy.js index 940a5018157..b61fe4fc62d 100644 --- a/lib/proxy-agent.js +++ b/lib/interceptor/proxy.js @@ -1,12 +1,12 @@ 'use strict' -const { kProxy, kClose, kDestroy } = require('./core/symbols') +const { kProxy, kClose, kDestroy } = require('../core/symbols') const { URL } = require('node:url') -const Agent = require('./agent') -const Pool = require('./pool') -const DispatcherBase = require('./dispatcher-base') -const { InvalidArgumentError, RequestAbortedError } = require('./core/errors') -const buildConnector = require('./core/connect') +const Agent = require('../agent') +const Pool = require('../pool') +const DispatcherBase = require('../dispatcher-base') +const { InvalidArgumentError, RequestAbortedError } = require('../core/errors') +const buildConnector = require('../core/connect') const kAgent = Symbol('proxy agent') const kClient = Symbol('proxy client') @@ -39,10 +39,10 @@ function defaultFactory (origin, opts) { } class ProxyAgent extends DispatcherBase { - constructor (opts) { + constructor (dispatcher, opts) { super(opts) this[kProxy] = buildProxyOptions(opts) - this[kAgent] = new Agent(opts) + this[kAgent] = dispatcher if (typeof opts === 'string') { opts = { uri: opts } @@ -183,4 +183,24 @@ function throwIfProxyAuthIsSent (headers) { } } -module.exports = ProxyAgent +module.exports = (opts) => { + if (typeof opts === 'string') { + opts = { uri: opts } + } + + if (!opts || !opts.uri) { + throw new InvalidArgumentError('Proxy opts.uri is mandatory') + } + + const { clientFactory = defaultFactory } = opts + + if (typeof clientFactory !== 'function') { + throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.') + } + + if (opts.auth && opts.token) { + throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') + } + + return (dispatcher) => new ProxyAgent(dispatcher, opts) +} diff --git a/lib/handler/RedirectHandler.js b/lib/interceptor/redirect.js similarity index 85% rename from lib/handler/RedirectHandler.js rename to lib/interceptor/redirect.js index aea13e99e29..21f3e44785b 100644 --- a/lib/handler/RedirectHandler.js +++ b/lib/interceptor/redirect.js @@ -5,6 +5,7 @@ const { kBodyUsed } = require('../core/symbols') const assert = require('node:assert') const { InvalidArgumentError } = require('../core/errors') const EE = require('node:events') +const Dispatcher = require('../dispatcher') const redirectableStatusCodes = [300, 301, 302, 303, 307, 308] @@ -24,27 +25,14 @@ class BodyAsyncIterable { } class RedirectHandler { - static buildDispatch (dispatcher, maxRedirections) { - if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - const dispatch = dispatcher.dispatch.bind(dispatcher) - return (opts, originalHandler) => dispatch(opts, new RedirectHandler(dispatch, maxRedirections, opts, originalHandler)) - } - - constructor (dispatch, maxRedirections, opts, handler) { - if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - util.validateHandler(handler, opts.method, opts.upgrade) + constructor (dispatcher, dispatcherOpts, redirectOpts, handler) { + const { maxRedirections } = redirectOpts ?? {} - this.dispatch = dispatch + this.dispatcher = dispatcher this.location = null this.abort = null - this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy - this.maxRedirections = maxRedirections + this.opts = { ...dispatcherOpts } // opts must be a copy + this.maxRedirections = maxRedirections ?? 0 this.handler = handler this.history = [] this.redirectionLimitReached = false @@ -177,7 +165,7 @@ class RedirectHandler { this.location = null this.abort = null - this.dispatch(this.opts, this) + this.dispatcher.dispatch(this.opts, this) } else { this.handler.onComplete(trailers) } @@ -232,4 +220,38 @@ function cleanRequestHeaders (headers, removeContent, unknownOrigin) { return ret } -module.exports = RedirectHandler +class RedirectDispatcher extends Dispatcher { + #opts + #dispatcher + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch(opts, new RedirectHandler(this.#dispatcher, opts, this.#opts, handler)) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +module.exports = (opts) => { + if (opts?.maxRedirections == null || opts?.maxRedirections === 0) { + return null + } + + if (!Number.isInteger(opts.maxRedirections) || opts.maxRedirections < 0) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + return (dispatcher) => new RedirectDispatcher(dispatcher, opts) +} diff --git a/lib/handler/RetryHandler.js b/lib/interceptor/retry.js similarity index 91% rename from lib/handler/RetryHandler.js rename to lib/interceptor/retry.js index 12dd0852393..7865010eb70 100644 --- a/lib/handler/RetryHandler.js +++ b/lib/interceptor/retry.js @@ -3,6 +3,7 @@ const assert = require('node:assert') const { kRetryHandlerDefaultRetry } = require('../core/symbols') const { RequestRetryError } = require('../core/errors') const { isDisturbed, parseHeaders, parseRangeHeader } = require('../core/util') +const Dispatcher = require('../dispatcher') function calculateRetryAfterHeader (retryAfter) { const current = Date.now() @@ -12,8 +13,7 @@ function calculateRetryAfterHeader (retryAfter) { } class RetryHandler { - constructor (opts, handlers) { - const { retryOptions, ...dispatchOpts } = opts + constructor (dispatcher, dispatchOpts, retryOpts, handler) { const { // Retry scoped retry: retryFn, @@ -26,10 +26,10 @@ class RetryHandler { errorCodes, retryAfter, statusCodes - } = retryOptions ?? {} + } = retryOpts ?? {} - this.dispatch = handlers.dispatch - this.handler = handlers.handler + this.dispatcher = dispatcher + this.handler = handler this.opts = dispatchOpts this.abort = null this.aborted = false @@ -324,7 +324,7 @@ class RetryHandler { } try { - this.dispatch(this.opts, this) + this.dispatcher.dispatch(this.opts, this) } catch (err) { this.handler.onError(err) } @@ -332,4 +332,30 @@ class RetryHandler { } } -module.exports = RetryHandler +class RetryDispatcher extends Dispatcher { + #dispatcher + #opts + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch(opts, new RetryHandler(this.#dispatcher, opts, this.#opts, handler)) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +module.exports = (opts) => { + return (dispatcher) => new RetryDispatcher(dispatcher, opts) +} diff --git a/lib/retry-agent.js b/lib/retry-agent.js deleted file mode 100644 index 9edb2aa529f..00000000000 --- a/lib/retry-agent.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict' - -const Dispatcher = require('./dispatcher') -const RetryHandler = require('./handler/RetryHandler') - -class RetryAgent extends Dispatcher { - #agent = null - #options = null - constructor (agent, options = {}) { - super(options) - this.#agent = agent - this.#options = options - } - - dispatch (opts, handler) { - const retry = new RetryHandler({ - ...opts, - retryOptions: this.#options - }, { - dispatch: this.#agent.dispatch.bind(this.#agent), - handler - }) - return this.#agent.dispatch(opts, retry) - } - - close () { - return this.#agent.close() - } - - destroy () { - return this.#agent.destroy() - } -} - -module.exports = RetryAgent diff --git a/test/mock-client.js b/test/mock-client.js index ef6721b42be..ba40816f5e2 100644 --- a/test/mock-client.js +++ b/test/mock-client.js @@ -122,16 +122,6 @@ test('MockClient - intercept should return a MockInterceptor', (t) => { }) describe('MockClient - intercept validation', () => { - test('it should error if no options specified in the intercept', t => { - t = tspl(t, { plan: 1 }) - const mockAgent = new MockAgent({ connections: 1 }) - after(() => mockAgent.close()) - - const mockClient = mockAgent.get('http://localhost:9999') - - t.throws(() => mockClient.intercept(), new InvalidArgumentError('opts must be an object')) - }) - test('it should error if no path specified in the intercept', t => { t = tspl(t, { plan: 1 }) const mockAgent = new MockAgent({ connections: 1 }) diff --git a/test/mock-pool.js b/test/mock-pool.js index 0b690fe6d48..85d3a6ce2fc 100644 --- a/test/mock-pool.js +++ b/test/mock-pool.js @@ -123,16 +123,6 @@ test('MockPool - intercept should return a MockInterceptor', (t) => { }) describe('MockPool - intercept validation', () => { - test('it should error if no options specified in the intercept', t => { - t = tspl(t, { plan: 1 }) - const mockAgent = new MockAgent() - after(() => mockAgent.close()) - - const mockPool = mockAgent.get('http://localhost:9999') - - t.throws(() => mockPool.intercept(), new InvalidArgumentError('opts must be an object')) - }) - test('it should error if no path specified in the intercept', t => { t = tspl(t, { plan: 1 }) const mockAgent = new MockAgent() diff --git a/test/proxy-agent.js b/test/proxy-agent.js index 87129d01472..f5cfebf770a 100644 --- a/test/proxy-agent.js +++ b/test/proxy-agent.js @@ -6,21 +6,22 @@ const { request, fetch, setGlobalDispatcher, getGlobalDispatcher } = require('.. const { InvalidArgumentError } = require('../lib/core/errors') const { readFileSync } = require('node:fs') const { join } = require('node:path') -const ProxyAgent = require('../lib/proxy-agent') +const proxyInterceptor = require('../lib/interceptor/proxy') const Pool = require('../lib/pool') +const Agent = require('../lib/agent') const { createServer } = require('node:http') const https = require('node:https') const proxy = require('proxy') test('should throw error when no uri is provided', (t) => { t = tspl(t, { plan: 2 }) - t.throws(() => new ProxyAgent(), InvalidArgumentError) - t.throws(() => new ProxyAgent({}), InvalidArgumentError) + t.throws(() => proxyInterceptor(), InvalidArgumentError) + t.throws(() => proxyInterceptor({}), InvalidArgumentError) }) test('using auth in combination with token should throw', (t) => { t = tspl(t, { plan: 1 }) - t.throws(() => new ProxyAgent({ + t.throws(() => proxyInterceptor({ auth: 'foo', token: 'Bearer bar', uri: 'http://example.com' @@ -31,8 +32,8 @@ test('using auth in combination with token should throw', (t) => { test('should accept string and object as options', (t) => { t = tspl(t, { plan: 2 }) - t.doesNotThrow(() => new ProxyAgent('http://example.com')) - t.doesNotThrow(() => new ProxyAgent({ uri: 'http://example.com' })) + t.doesNotThrow(() => proxyInterceptor('http://example.com')) + t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) }) test('use proxy-agent to connect through proxy', async (t) => { @@ -43,7 +44,7 @@ test('use proxy-agent to connect through proxy', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) const parsedOrigin = new URL(serverUrl) proxy.on('connect', () => { @@ -102,7 +103,7 @@ test('use proxy agent to connect through proxy using Pool', async (t) => { const clientFactory = (url, options) => { return new Pool(url, options) } - const proxyAgent = new ProxyAgent({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory }) + const proxyAgent = new Agent().compose(proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory })) const firstRequest = request(`${serverUrl}`, { dispatcher: proxyAgent }) const secondRequest = await request(`${serverUrl}`, { dispatcher: proxyAgent }) t.strictEqual((await firstRequest).statusCode, 200) @@ -119,7 +120,7 @@ test('use proxy-agent to connect through proxy using path with params', async (t const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) const parsedOrigin = new URL(serverUrl) proxy.on('connect', () => { @@ -155,10 +156,10 @@ test('use proxy-agent with auth', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl - }) + })) const parsedOrigin = new URL(serverUrl) proxy.authenticate = function (req, fn) { @@ -199,10 +200,10 @@ test('use proxy-agent with token', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, uri: proxyUrl - }) + })) const parsedOrigin = new URL(serverUrl) proxy.authenticate = function (req, fn) { @@ -243,12 +244,12 @@ test('use proxy-agent with custom headers', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ uri: proxyUrl, headers: { 'User-Agent': 'Foobar/1.0.0' } - }) + })) proxy.on('connect', (req) => { t.strictEqual(req.headers['user-agent'], 'Foobar/1.0.0') @@ -276,7 +277,7 @@ test('sending proxy-authorization in request headers should throw', async (t) => const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) server.on('request', (req, res) => { res.end(JSON.stringify({ hello: 'world' })) @@ -335,7 +336,7 @@ test('use proxy-agent with setGlobalDispatcher', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) const parsedOrigin = new URL(serverUrl) setGlobalDispatcher(proxyAgent) @@ -377,7 +378,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) setGlobalDispatcher(proxyAgent) after(() => setGlobalDispatcher(defaultDispatcher)) @@ -430,7 +431,7 @@ test('should throw when proxy does not return 200', async (t) => { fn(null, false) } - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) try { await request(serverUrl, { dispatcher: proxyAgent }) t.fail() @@ -457,7 +458,7 @@ test('pass ProxyAgent proxy status code error when using fetch - #2161', async ( fn(null, false) } - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) try { await fetch(serverUrl, { dispatcher: proxyAgent }) } catch (e) { @@ -479,7 +480,7 @@ test('Proxy via HTTP to HTTPS endpoint', async (t) => { const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ uri: proxyUrl, requestTls: { ca: [ @@ -489,7 +490,7 @@ test('Proxy via HTTP to HTTPS endpoint', async (t) => { cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), servername: 'agent1' } - }) + })) server.on('request', function (req, res) { t.ok(req.connection.encrypted) @@ -531,7 +532,7 @@ test('Proxy via HTTPS to HTTPS endpoint', async (t) => { const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ uri: proxyUrl, proxyTls: { ca: [ @@ -550,7 +551,7 @@ test('Proxy via HTTPS to HTTPS endpoint', async (t) => { cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), servername: 'agent1' } - }) + })) server.on('request', function (req, res) { t.ok(req.connection.encrypted) @@ -592,7 +593,7 @@ test('Proxy via HTTPS to HTTP endpoint', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent({ + const proxyAgent = new Agent().compose(proxyInterceptor({ uri: proxyUrl, proxyTls: { ca: [ @@ -603,7 +604,7 @@ test('Proxy via HTTPS to HTTP endpoint', async (t) => { servername: 'agent1', rejectUnauthorized: false } - }) + })) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) @@ -641,7 +642,7 @@ test('Proxy via HTTP to HTTP endpoint', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new Agent().compose(proxyInterceptor(proxyUrl)) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) diff --git a/test/retry-agent.js b/test/retry-agent.js index 0e5e252d954..27604d00436 100644 --- a/test/retry-agent.js +++ b/test/retry-agent.js @@ -5,17 +5,12 @@ const { test, after } = require('node:test') const { createServer } = require('node:http') const { once } = require('node:events') -const { RetryAgent, Client } = require('..') +const { interceptor, Client } = require('..') test('Should retry status code', async t => { t = tspl(t, { plan: 2 }) let counter = 0 const server = createServer() - const opts = { - maxRetries: 5, - timeout: 1, - timeoutFactor: 1 - } server.on('request', (req, res) => { switch (counter++) { @@ -37,7 +32,11 @@ test('Should retry status code', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const agent = new RetryAgent(client, opts) + const agent = client.intercept(interceptor.retry({ + maxRetries: 5, + timeout: 1, + timeoutFactor: 1 + })) after(async () => { await agent.close() diff --git a/test/retry-handler.js b/test/retry-handler.js index 5dc1adb8150..0ee1b8cc69c 100644 --- a/test/retry-handler.js +++ b/test/retry-handler.js @@ -5,7 +5,8 @@ const { test, after } = require('node:test') const { createServer } = require('node:http') const { once } = require('node:events') -const { RetryHandler, Client } = require('..') +const { Client } = require('..') +const retry = require('../lib/interceptor/retry') const { RequestHandler } = require('../lib/api/api-request') test('Should retry status code', async t => { @@ -14,28 +15,6 @@ test('Should retry status code', async t => { let counter = 0 const chunks = [] const server = createServer() - const dispatchOptions = { - retryOptions: { - retry: (err, { state, opts }, done) => { - counter++ - - if ( - err.statusCode === 500 || - err.message.includes('other side closed') - ) { - setTimeout(done, 500) - return - } - - return done(err) - } - }, - method: 'GET', - path: '/', - headers: { - 'content-type': 'application/json' - } - } server.on('request', (req, res) => { switch (counter) { @@ -57,29 +36,26 @@ test('Should retry status code', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 200) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') - t.strictEqual(counter, 2) - }, - onError () { - t.fail() - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + t.strictEqual(counter, 2) + }, + onError () { + t.fail() } - }) + } after(async () => { await client.close() @@ -88,7 +64,21 @@ test('Should retry status code', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry({ + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + })).dispatch( { method: 'GET', path: '/', @@ -110,13 +100,6 @@ test('Should use retry-after header for retries', async t => { const chunks = [] const server = createServer() let checkpoint - const dispatchOptions = { - method: 'PUT', - path: '/', - headers: { - 'content-type': 'application/json' - } - } server.on('request', (req, res) => { switch (counter) { @@ -141,28 +124,25 @@ test('Should use retry-after header for retries', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 200) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') - }, - onError (err) { - t.ifError(err) - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.ifError(err) } - }) + } after(async () => { await client.close() @@ -171,7 +151,9 @@ test('Should use retry-after header for retries', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry({ + + })).dispatch( { method: 'PUT', path: '/', @@ -193,13 +175,6 @@ test('Should use retry-after header for retries (date)', async t => { const chunks = [] const server = createServer() let checkpoint - const dispatchOptions = { - method: 'PUT', - path: '/', - headers: { - 'content-type': 'application/json' - } - } server.on('request', (req, res) => { switch (counter) { @@ -226,28 +201,25 @@ test('Should use retry-after header for retries (date)', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 200) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') - }, - onError (err) { - t.ifError(err) - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.ifError(err) } - }) + } after(async () => { await client.close() @@ -256,7 +228,8 @@ test('Should use retry-after header for retries (date)', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry({ + })).dispatch( { method: 'PUT', path: '/', @@ -277,13 +250,6 @@ test('Should retry with defaults', async t => { let counter = 0 const chunks = [] const server = createServer() - const dispatchOptions = { - method: 'GET', - path: '/', - headers: { - 'content-type': 'application/json' - } - } server.on('request', (req, res) => { switch (counter) { @@ -308,28 +274,25 @@ test('Should retry with defaults', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 200) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') - }, - onError (err) { - t.ifError(err) - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.ifError(err) } - }) + } after(async () => { await client.close() @@ -338,7 +301,7 @@ test('Should retry with defaults', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry()).dispatch( { method: 'GET', path: '/', @@ -379,8 +342,30 @@ test('Should handle 206 partial content', async t => { x++ }) - const dispatchOptions = { - retryOptions: { + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') + t.strictEqual(counter, 1) + }, + onError () { + t.fail() + } + } + + client.compose(retry({ retry: function (err, _, done) { counter++ @@ -392,43 +377,7 @@ test('Should handle 206 partial content', async t => { setTimeout(done, 800) } - }, - method: 'GET', - path: '/', - headers: { - 'content-type': 'application/json' - } - } - - server.listen(0, () => { - const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: (...args) => { - return client.dispatch(...args) - }, - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 200) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') - t.strictEqual(counter, 1) - }, - onError () { - t.fail() - } - } - }) - - client.dispatch( + })).dispatch( { method: 'GET', path: '/', @@ -475,46 +424,30 @@ test('Should handle 206 partial content - bad-etag', async t => { x++ }) - const dispatchOptions = { - method: 'GET', - path: '/', - headers: { - 'content-type': 'application/json' - } - } - server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler( - dispatchOptions, - { - dispatch: (...args) => { - return client.dispatch(...args) - }, - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.ok(true, 'pass') - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.ifError('should not complete') - }, - onError (err) { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') - t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') - } - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.ok(true, 'pass') + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.ifError('should not complete') + }, + onError (err) { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') + t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') } - ) + } - client.dispatch( + client.compose(retry()).dispatch( { method: 'GET', path: '/', @@ -539,29 +472,6 @@ test('Should handle 206 partial content - bad-etag', async t => { test('retrying a request with a body', async t => { let counter = 0 const server = createServer() - const dispatchOptions = { - retryOptions: { - retry: (err, { state, opts }, done) => { - counter++ - - if ( - err.statusCode === 500 || - err.message.includes('other side closed') - ) { - setTimeout(done, 500) - return - } - - return done(err) - } - }, - method: 'POST', - path: '/', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ hello: 'world' }) - } t = tspl(t, { plan: 1 }) @@ -585,11 +495,15 @@ test('retrying a request with a body', async t => { server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: new RequestHandler(dispatchOptions, (err, data) => { - t.ifError(err) - }) + const handler = new RequestHandler({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ hello: 'world' }) + }, (err, data) => { + t.ifError(err) }) after(async () => { @@ -599,7 +513,21 @@ test('retrying a request with a body', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry({ + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + })).dispatch( { method: 'POST', path: '/', @@ -624,42 +552,29 @@ test('should not error if request is not meant to be retried', async t => { res.end('Bad request') }) - const dispatchOptions = { - retryOptions: { - method: 'GET', - path: '/', - headers: { - 'content-type': 'application/json' - } - } - } - server.listen(0, () => { const client = new Client(`http://localhost:${server.address().port}`) const chunks = [] - const handler = new RetryHandler(dispatchOptions, { - dispatch: client.dispatch.bind(client), - handler: { - onConnect () { - t.ok(true, 'pass') - }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { - t.strictEqual(status, 400) - return true - }, - onData (chunk) { - chunks.push(chunk) - return true - }, - onComplete () { - t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'Bad request') - }, - onError (err) { - console.log({ err }) - t.fail() - } + const handler = { + onConnect () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.strictEqual(status, 400) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'Bad request') + }, + onError (err) { + console.log({ err }) + t.fail() } - }) + } after(async () => { await client.close() @@ -668,7 +583,7 @@ test('should not error if request is not meant to be retried', async t => { await once(server, 'close') }) - client.dispatch( + client.compose(retry()).dispatch( { method: 'GET', path: '/',