From ae2b23267c2538518c9eafb6ee30dbead18f054a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 21 Feb 2024 12:30:40 +0100 Subject: [PATCH] Add RetryAgent (#2798) Signed-off-by: Matteo Collina --- docs/api/RetryAgent.md | 45 +++++++++++++++++++++ docs/api/RetryHandler.md | 2 +- docsify/sidebar.md | 1 + index.js | 2 + lib/handler/RetryHandler.js | 4 +- lib/retry-agent.js | 35 +++++++++++++++++ test/retry-agent.js | 67 ++++++++++++++++++++++++++++++++ test/types/retry-agent.test-d.ts | 17 ++++++++ types/index.d.ts | 3 +- types/retry-agent.d.ts | 11 ++++++ 10 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 docs/api/RetryAgent.md create mode 100644 lib/retry-agent.js create mode 100644 test/retry-agent.js create mode 100644 test/types/retry-agent.test-d.ts create mode 100644 types/retry-agent.d.ts diff --git a/docs/api/RetryAgent.md b/docs/api/RetryAgent.md new file mode 100644 index 00000000000..a1f38b3adb8 --- /dev/null +++ b/docs/api/RetryAgent.md @@ -0,0 +1,45 @@ +# Class: RetryAgent + +Extends: `undici.Dispatcher` + +A `undici.Dispatcher` that allows to automatically retry a request. +Wraps a `undici.RetryHandler`. + +## `new RetryAgent(dispatcher, [options])` + +Arguments: + +* **dispatcher** `undici.Dispatcher` (required) - the dispactgher to wrap +* **options** `RetryHandlerOptions` (optional) - the options + +Returns: `ProxyAgent` + +### Parameter: `RetryHandlerOptions` + +- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed. +- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5` +- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds) +- **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second) +- **timeoutFactor** `number` (optional) - Factor to multiply the timeout by for each retry attempt. Default: `2` +- **retryAfter** `boolean` (optional) - It enables automatic retry after the `Retry-After` header is received. Default: `true` +- +- **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']` +- **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]` +- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', 'UND_ERR_SOCKET']` + +**`RetryContext`** + +- `state`: `RetryState` - Current retry state. It can be mutated. +- `opts`: `Dispatch.DispatchOptions & RetryOptions` - Options passed to the retry handler. + +Example: + +```js +import { Agent, RetryAgent } from 'undici' + +const agent = new RetryAgent(new Agent()) + +const res = await agent.request('http://example.com') +console.log(res.statuCode) +console.log(await res.body.text()) +``` diff --git a/docs/api/RetryHandler.md b/docs/api/RetryHandler.md index 9b5437db958..596322472c7 100644 --- a/docs/api/RetryHandler.md +++ b/docs/api/RetryHandler.md @@ -28,7 +28,7 @@ Extends: [`Dispatch.DispatchOptions`](Dispatcher.md#parameter-dispatchoptions). - - **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']` - **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]` -- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', +- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', 'UND_ERR_SOCKET']` **`RetryContext`** diff --git a/docsify/sidebar.md b/docsify/sidebar.md index 0a0fb04e177..674c0dad0e7 100644 --- a/docsify/sidebar.md +++ b/docsify/sidebar.md @@ -8,6 +8,7 @@ * [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool") * [Agent](/docs/api/Agent.md "Undici API - Agent") * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") + * [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent") * [Connector](/docs/api/Connector.md "Custom connector") * [Errors](/docs/api/Errors.md "Undici API - Errors") * [EventSource](/docs/api/EventSource.md "Undici API - EventSource") diff --git a/index.js b/index.js index f751d8cbb6e..7255334c9c3 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ 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') @@ -28,6 +29,7 @@ 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 diff --git a/lib/handler/RetryHandler.js b/lib/handler/RetryHandler.js index 5cf36c772af..12dd0852393 100644 --- a/lib/handler/RetryHandler.js +++ b/lib/handler/RetryHandler.js @@ -53,7 +53,8 @@ class RetryHandler { 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', - 'EPIPE' + 'EPIPE', + 'UND_ERR_SOCKET' ] } @@ -109,7 +110,6 @@ class RetryHandler { if ( code && code !== 'UND_ERR_REQ_RETRY' && - code !== 'UND_ERR_SOCKET' && !errorCodes.includes(code) ) { cb(err) diff --git a/lib/retry-agent.js b/lib/retry-agent.js new file mode 100644 index 00000000000..9edb2aa529f --- /dev/null +++ b/lib/retry-agent.js @@ -0,0 +1,35 @@ +'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/retry-agent.js b/test/retry-agent.js new file mode 100644 index 00000000000..0e5e252d954 --- /dev/null +++ b/test/retry-agent.js @@ -0,0 +1,67 @@ +'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 { RetryAgent, 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++) { + 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, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const agent = new RetryAgent(client, opts) + + after(async () => { + await agent.close() + server.close() + + await once(server, 'close') + }) + + agent.request({ + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }).then((res) => { + t.equal(res.statusCode, 200) + res.body.setEncoding('utf8') + let chunks = '' + res.body.on('data', chunk => { chunks += chunk }) + res.body.on('end', () => { + t.equal(chunks, 'hello world!') + }) + }) + }) + + await t.completed +}) diff --git a/test/types/retry-agent.test-d.ts b/test/types/retry-agent.test-d.ts new file mode 100644 index 00000000000..9177efd07ff --- /dev/null +++ b/test/types/retry-agent.test-d.ts @@ -0,0 +1,17 @@ +import { expectAssignable } from 'tsd' +import { RetryAgent, Agent } from '../..' + +const dispatcher = new Agent() + +expectAssignable(new RetryAgent(dispatcher)) +expectAssignable(new RetryAgent(dispatcher, { maxRetries: 5 })) + +{ + const retryAgent = new RetryAgent(dispatcher) + + // close + expectAssignable>(retryAgent.close()) + + // dispatch + expectAssignable(retryAgent.dispatch({ origin: '', path: '', method: 'GET' }, {})) +} diff --git a/types/index.d.ts b/types/index.d.ts index 56e3730e62d..8fea77bff00 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -15,6 +15,7 @@ import MockAgent from'./mock-agent' import mockErrors from'./mock-errors' import ProxyAgent from'./proxy-agent' import RetryHandler from'./retry-handler' +import RetryAgent from'./retry-agent' import { request, pipeline, stream, connect, upgrade } from './api' export * from './util' @@ -30,7 +31,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } export default Undici declare namespace Undici { diff --git a/types/retry-agent.d.ts b/types/retry-agent.d.ts new file mode 100644 index 00000000000..cf559d956e5 --- /dev/null +++ b/types/retry-agent.d.ts @@ -0,0 +1,11 @@ +import Agent from './agent' +import buildConnector from './connector'; +import Dispatcher from './dispatcher' +import { IncomingHttpHeaders } from './header' +import RetryHandler from './retry-handler' + +export default RetryAgent + +declare class RetryAgent extends Dispatcher { + constructor(dispatcher: Dispatcher, options?: RetryHandler.RetryOptions) +}