diff --git a/README.md b/README.md
index 6c6f18e7..f63deeb8 100644
--- a/README.md
+++ b/README.md
@@ -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.
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.
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`.
+`@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.`
` 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.`
` 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`.`
`
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
@@ -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
@@ -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/',
@@ -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`.
@@ -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'
@@ -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: {
}
})
@@ -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}
+})
+
+```
---
@@ -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
@@ -341,17 +378,18 @@ 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, {
@@ -359,18 +397,17 @@ server.register(fastifyHttpProxy, {
// 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.
@@ -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)
@@ -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`).
@@ -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
diff --git a/index.js b/index.js
index b8506271..6c0c36e6 100644
--- a/index.js
+++ b/index.js
@@ -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
@@ -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
@@ -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
}
@@ -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)
})
diff --git a/test/retry-with-a-custom-handler.test.js b/test/retry-with-a-custom-handler.test.js
new file mode 100644
index 00000000..23b4ab32
--- /dev/null
+++ b/test/retry-with-a-custom-handler.test.js
@@ -0,0 +1,120 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const From = require('..')
+const http = require('node:http')
+const got = require('got')
+
+function serverWithCustomError (stopAfter, statusCodeToFailOn) {
+ let requestCount = 0
+ return http.createServer((req, res) => {
+ if (requestCount++ < stopAfter) {
+ res.statusCode = statusCodeToFailOn
+ res.setHeader('Content-Type', 'text/plain')
+ return res.end('This Service is Unavailable')
+ } else {
+ res.statusCode = 205
+ res.setHeader('Content-Type', 'text/plain')
+ return res.end(`Hello World ${requestCount}!`)
+ }
+ })
+}
+
+async function setupServer (t, fromOptions = {}, statusCodeToFailOn = 500, stopAfter = 4) {
+ const target = serverWithCustomError(stopAfter, statusCodeToFailOn)
+ await target.listen({ port: 0 })
+ t.teardown(target.close.bind(target))
+
+ const instance = Fastify()
+ instance.register(From, {
+ base: `http://localhost:${target.address().port}`
+ })
+
+ instance.get('/', (request, reply) => {
+ reply.from(`http://localhost:${target.address().port}`, fromOptions)
+ })
+
+ t.teardown(instance.close.bind(instance))
+ await instance.listen({ port: 0 })
+
+ return {
+ instance
+ }
+}
+
+test('a 500 status code with no custom handler should fail', async (t) => {
+ const { instance } = await setupServer(t)
+
+ let errorMessage
+ try {
+ await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 })
+ } catch (error) {
+ errorMessage = error.message
+ }
+
+ t.equal(errorMessage, 'Response code 500 (Internal Server Error)')
+})
+
+test("a server 500's with a custom handler and should revive", async (t) => {
+ const customRetryLogic = (req, res, getDefaultDelay) => {
+ const defaultDelay = getDefaultDelay()
+ if (defaultDelay) return defaultDelay
+
+ if (res && res.statusCode === 500 && req.method === 'GET') {
+ return 300
+ }
+ return null
+ }
+
+ const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } })
+
+ const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 })
+
+ t.equal(res.headers['content-type'], 'text/plain')
+ t.equal(res.statusCode, 205)
+ t.equal(res.body.toString(), 'Hello World 5!')
+})
+
+test('custom retry does not invoke the default delay causing a 503', async (t) => {
+ // the key here is our customRetryHandler doesn't register the deefault handler and as a result it doesn't work
+ const customRetryLogic = (req, res, getDefaultDelay) => {
+ if (res && res.statusCode === 500 && req.method === 'GET') {
+ return 300
+ }
+ return null
+ }
+
+ const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }, 503)
+
+ let errorMessage
+ try {
+ await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 })
+ } catch (error) {
+ errorMessage = error.message
+ }
+
+ t.equal(errorMessage, 'Response code 503 (Service Unavailable)')
+})
+
+test('custom retry delay functions can invoke the default delay', async (t) => {
+ const customRetryLogic = (req, res, getDefaultDelay) => {
+ // registering the default retry logic for non 500 errors if it occurs
+ const defaultDelay = getDefaultDelay()
+ if (defaultDelay) return defaultDelay
+
+ if (res && res.statusCode === 500 && req.method === 'GET') {
+ return 300
+ }
+
+ return null
+ }
+
+ const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }, 503)
+
+ const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 })
+
+ t.equal(res.headers['content-type'], 'text/plain')
+ t.equal(res.statusCode, 205)
+ t.equal(res.body.toString(), 'Hello World 5!')
+})
diff --git a/types/index.test-d.ts b/types/index.test-d.ts
index cb149562..0330f967 100644
--- a/types/index.test-d.ts
+++ b/types/index.test-d.ts
@@ -1,12 +1,12 @@
-import replyFrom, { FastifyReplyFromOptions } from "..";
-import fastify, {FastifyReply, FastifyRequest, RawServerBase, RequestGenericInterface} from "fastify";
-import { AddressInfo } from "net";
-import { IncomingHttpHeaders } from "http2";
-import { expectType } from 'tsd';
+import fastify, { FastifyReply, FastifyRequest, RawServerBase, RequestGenericInterface } from "fastify";
import * as http from 'http';
+import { IncomingHttpHeaders } from "http2";
import * as https from 'https';
+import { AddressInfo } from "net";
+import { expectType } from 'tsd';
+import replyFrom, { FastifyReplyFromOptions } from "..";
// @ts-ignore
-import tap from 'tap'
+import tap from 'tap';
const fullOptions: FastifyReplyFromOptions = {
base: "http://example2.com",