Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RetryAgent #2798

Merged
merged 1 commit into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -29,6 +30,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 @@ -119,7 +120,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)
}