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",