Skip to content

Commit

Permalink
Add RetryAgent (#2798)
Browse files Browse the repository at this point in the history
Signed-off-by: Matteo Collina <hello@matteocollina.com>
  • Loading branch information
mcollina authored and ronag committed Feb 22, 2024
1 parent 3f08197 commit ae2b232
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 4 deletions.
45 changes: 45 additions & 0 deletions docs/api/RetryAgent.md
Original file line number Diff line number Diff line change
@@ -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())
```
2 changes: 1 addition & 1 deletion docs/api/RetryHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**

Expand Down
1 change: 1 addition & 0 deletions docsify/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/handler/RetryHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ class RetryHandler {
'ENETUNREACH',
'EHOSTDOWN',
'EHOSTUNREACH',
'EPIPE'
'EPIPE',
'UND_ERR_SOCKET'
]
}

Expand Down Expand Up @@ -109,7 +110,6 @@ class RetryHandler {
if (
code &&
code !== 'UND_ERR_REQ_RETRY' &&
code !== 'UND_ERR_SOCKET' &&
!errorCodes.includes(code)
) {
cb(err)
Expand Down
35 changes: 35 additions & 0 deletions lib/retry-agent.js
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions test/retry-agent.js
Original file line number Diff line number Diff line change
@@ -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
})
17 changes: 17 additions & 0 deletions test/types/retry-agent.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expectAssignable } from 'tsd'
import { RetryAgent, Agent } from '../..'

const dispatcher = new Agent()

expectAssignable<RetryAgent>(new RetryAgent(dispatcher))
expectAssignable<RetryAgent>(new RetryAgent(dispatcher, { maxRetries: 5 }))

{
const retryAgent = new RetryAgent(dispatcher)

// close
expectAssignable<Promise<void>>(retryAgent.close())

// dispatch
expectAssignable<boolean>(retryAgent.dispatch({ origin: '', path: '', method: 'GET' }, {}))
}
3 changes: 2 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions types/retry-agent.d.ts
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit ae2b232

Please sign in to comment.