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

[feature] custom retry callback #332

Merged
merged 6 commits into from
Nov 21, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 58 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ npm i @fastify/reply-from
```

## Compatibility with @fastify/multipart
`@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins which have a parent-child relationship.<br> The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.<br> This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.<br>
`@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins which have a parent-child relationship.`<br>` The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.`<br>` This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.`<br>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you avoid making these unrelated changes in this PR 🙏

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I ran lint incorrectly my bad !
Will fix.

However, the two plugins may be used within the same fastify instance, at the condition that they belong to disjoint branches of the fastify plugins hierarchy tree.

## Usage
Expand Down Expand Up @@ -115,7 +115,7 @@ proxy.register(require('@fastify/reply-from'), {

#### `http`

Set the `http` option to an Object to use
Set the `http` option to an Object to use
Node's [`http.request`](https://nodejs.org/api/http.html#http_http_request_options_callback)
will be used if you do not enable [`http2`](#http2). To customize the `request`,
you can pass in [`agentOptions`](https://nodejs.org/api/http.html#http_new_agent_options) and
Expand All @@ -136,6 +136,7 @@ proxy.register(require('@fastify/reply-from'), {
```

You can also pass custom HTTP agents. If you pass the agents, then the http.agentOptions will be ignored. To illustrate:

```js
proxy.register(require('@fastify/reply-from'), {
base: 'http://localhost:3001/',
Expand Down Expand Up @@ -192,27 +193,27 @@ The number of parsed URLs that will be cached. Default: `100`.

#### `disableCache`

This option will disable the URL caching.
This option will disable the URL caching.
This cache is dedicated to reduce the amount of URL object generation.
Generating URLs is a main bottleneck of this module, please disable this cache with caution.

#### `contentTypesToEncode`

An array of content types whose response body will be passed through `JSON.stringify()`.
An array of content types whose response body will be passed through `JSON.stringify()`.
This only applies when a custom [`body`](#body) is not passed in. Defaults to:

```js
[
[
'application/json'
]
```

#### `retryMethods`

On which methods should the connection be retried in case of socket hang up.
On which methods should the connection be retried in case of socket hang up.
**Be aware** that setting here not idempotent method may lead to unexpected results on target.

By default: `['GET', 'HEAD', 'OPTIONS', 'TRACE' ]`
By default: `['GET', 'HEAD', 'OPTIONS', 'TRACE']`

This plugin will always retry on 503 errors, _unless_ `retryMethods` does not contain `GET`.

Expand All @@ -221,6 +222,7 @@ This plugin will always retry on 503 errors, _unless_ `retryMethods` does not co
Enables the possibility to explictly opt-in for global agents.

Usage for undici global agent:

```js
import { setGlobalDispatcher, ProxyAgent } from 'undici'

Expand All @@ -234,11 +236,12 @@ fastify.register(FastifyReplyFrom, {
```

Usage for http/https global agent:

```js
fastify.register(FastifyReplyFrom, {
base: 'http://localhost:3001/',
// http and https is allowed to use http.globalAgent or https.globalAgent
globalAgent: true,
globalAgent: true,
http: {
}
})
Expand All @@ -263,6 +266,39 @@ This option set the limit on how many times the plugin should retry the request,

By Default: 10

---

### `customRetry`

- `handler`. Required
- `retries`. Optional

This plugin gives the client an option to pass their own retry callback to handle retries on their own.
If a `handler` is passed to the `customRetry` object the onus is on the client to invoke the default retry logic in their callback otherwise default cases such as 503 will not be handled

Given example

```js
const customRetryLogic = (req, res, getDefaultRetry) => {
//If this block is not included all non 500 errors will not be retried
const defaultDelay = getDefaultDelay();
if (defaultDelay) return defaultDelay();

//Custom retry logic
if (res && res.statusCode === 500 && req.method === 'GET') {
return 300
}
return null
}

.......

fastify.register(FastifyReplyFrom, {
base: 'http://localhost:3001/',
customRetry: {handler: customRetryLogic, retries: 10}
})

```

---

Expand Down Expand Up @@ -328,6 +364,7 @@ ContraintStrategies.
e.g.:

Route grpc-web/http1 and grpc/http2 to different routes with a ContentType-ConstraintStrategy:

```
const contentTypeMatchContraintStrategy = {
// strategy name for referencing in the route handler `constraints` options
Expand All @@ -341,36 +378,36 @@ const contentTypeMatchContraintStrategy = {
}
},
// function to get the value of the constraint from each incoming request
deriveConstraint: (req: any, ctx: any) => {
deriveConstraint: (req: any, ctx: any) => {
return req.headers['content-type']
},
// optional flag marking if handlers without constraints can match requests that have a value for this constraint
mustMatchWhenDerived: true
}

server.addConstraintStrategy(contentTypeMatchContraintStrategy);
```

and then 2 different upstreams with different register's:

```
// grpc-web / http1
server.register(fastifyHttpProxy, {
// Although most browsers send with http2, nodejs cannot handle this http2 request
// therefore we have to transport to the grpc-web-proxy via http1
http2: false,
upstream: 'http://grpc-web-proxy',
constraints: { "contentType": "application/grpc-web+proto" }
constraints: { "contentType": "application/grpc-web+proto" }
});

// grpc / http2
server.register(fastifyHttpProxy, {
http2: true,
upstream: 'http://grpc.server',
constraints: { "contentType": "application/grpc+proto" }
constraints: { "contentType": "application/grpc+proto" }
});
```


#### `queryString` or `queryString(search, reqUrl)`

Replaces the original querystring of the request with what is specified.
Expand All @@ -390,12 +427,12 @@ Setting this option to `null` will strip the body (and `content-type` header) en

#### `method`

Replaces the original request method with what is specified.
Replaces the original request method with what is specified.

#### `retriesCount`

How many times it will try to pick another connection on socket hangup (`ECONNRESET` error).
Useful when keeping the connection open (KeepAlive).
How many times it will try to pick another connection on socket hangup (`ECONNRESET` error).
Useful when keeping the connection open (KeepAlive).
This number should be a function of the number of connections and the number of instances of a target.

By default: 0 (disabled)
Expand All @@ -407,13 +444,13 @@ already overriding the [`body`](#body).

### Combining with [@fastify/formbody](https://github.com/fastify/fastify-formbody)

`formbody` expects the body to be returned as a string and not an object.
`formbody` expects the body to be returned as a string and not an object.
Use the [`contentTypesToEncode`](#contentTypesToEncode) option to pass in `['application/x-www-form-urlencoded']`


### HTTP & HTTP2 timeouts

This library has:

- `timeout` for `http` set by default. The default value is 10 seconds (`10000`).
- `requestTimeout` & `sessionTimeout` for `http2` set by default.
- The default value for `requestTimeout` is 10 seconds (`10000`).
Expand All @@ -426,10 +463,10 @@ will be returned to the client.

* [ ] support overriding the body with a stream
* [ ] forward the request id to the other peer might require some
refactoring because we have to make the `req.id` unique
(see [hyperid](https://npm.im/hyperid)).
refactoring because we have to make the `req.id` unique
(see [hyperid](https://npm.im/hyperid)).
* [ ] Support origin HTTP2 push
* [x] benchmarks
* [X] benchmarks

## License

Expand Down
55 changes: 35 additions & 20 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
])

const retryMethods = new Set(opts.retryMethods || [
'GET', 'HEAD', 'OPTIONS', 'TRACE'
])
'GET', 'HEAD', 'OPTIONS', 'TRACE'])

const cache = opts.disableCache ? undefined : lru(opts.cacheURLs || 100)
const base = opts.base
Expand Down Expand Up @@ -60,6 +59,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
const onError = opts.onError || onErrorDefault
const retriesCount = opts.retriesCount || 0
const maxRetriesOn503 = opts.maxRetriesOn503 || 10
const customRetry = opts.customRetry || undefined

if (!source) {
source = req.url
Expand Down Expand Up @@ -143,7 +143,35 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
const contentLength = requestHeaders['content-length']
let requestImpl
if (retryMethods.has(method) && !contentLength) {
requestImpl = createRequestRetry(request, this, retriesCount, retryOnError, maxRetriesOn503)
const retryHandler = (req, res, err, retries) => {
const defaultDelay = () => {
// Magic number, so why not 42? We might want to make this configurable.
let retryAfter = 42 * Math.random() * (retries + 1)

if (res && res.headers['retry-after']) {
retryAfter = res.headers['retry-after']
}
if (res && res.statusCode === 503 && req.method === 'GET') {
if (retriesCount === 0 && retries < maxRetriesOn503) {
// we should stop at some point
return retryAfter
}
} else if (retriesCount > retries && err && err.code === retryOnError) {
return retryAfter
}
return null
}

if (customRetry && customRetry.handler) {
const customRetries = customRetry.retries || 1
if (++retries < customRetries) {
return customRetry.handler(req, res, defaultDelay)
}
}
return defaultDelay()
}

requestImpl = createRequestRetry(request, this, retryHandler)
} else {
requestImpl = request
}
Expand Down Expand Up @@ -251,28 +279,15 @@ function isFastifyMultipartRegistered (fastify) {
return (fastify.hasContentTypeParser('multipart') || fastify.hasContentTypeParser('multipart/form-data')) && fastify.hasRequestDecorator('multipart')
}

function createRequestRetry (requestImpl, reply, retriesCount, retryOnError, maxRetriesOn503) {
function createRequestRetry (requestImpl, reply, retryHandler) {
function requestRetry (req, cb) {
let retries = 0

function run () {
requestImpl(req, function (err, res) {
// Magic number, so why not 42? We might want to make this configurable.
let retryAfter = 42 * Math.random() * (retries + 1)

if (res && res.headers['retry-after']) {
retryAfter = res.headers['retry-after']
}
if (!reply.sent) {
// always retry on 503 errors
if (res && res.statusCode === 503 && req.method === 'GET') {
if (retriesCount === 0 && retries < maxRetriesOn503) {
// we should stop at some point
return retry(retryAfter)
}
} else if (retriesCount > retries && err && err.code === retryOnError) {
return retry(retryAfter)
}
const retryDelay = retryHandler(req, res, err, retries)
if (!reply.sent && retryDelay) {
return retry(retryDelay)
}
cb(err, res)
})
Expand Down
Loading