diff --git a/README.md b/README.md index 6c6f18e7..860b0523 100644 --- a/README.md +++ b/README.md @@ -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 @@ -192,27 +192,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', 'POST', 'PATCH']` This plugin will always retry on 503 errors, _unless_ `retryMethods` does not contain `GET`. @@ -238,7 +238,7 @@ Usage for http/https global agent: fastify.register(FastifyReplyFrom, { base: 'http://localhost:3001/', // http and https is allowed to use http.globalAgent or https.globalAgent - globalAgent: true, + globalAgent: true, http: { } }) @@ -265,6 +265,39 @@ 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, registerDefaultRetry, defaultRetryAfter) => { + //If this block is not included all non 500 errors will not be retried + if (registerDefaultRetry()){ + return defaultRetryAfter; + } + + //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} +}) + +``` +--- + + ### `reply.from(source, [opts])` @@ -341,13 +374,13 @@ 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); ``` @@ -359,14 +392,14 @@ 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" } }); ``` @@ -390,12 +423,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,7 +440,7 @@ 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']` diff --git a/index.js b/index.js index b8506271..3c70ddb3 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) { ]) const retryMethods = new Set(opts.retryMethods || [ - 'GET', 'HEAD', 'OPTIONS', 'TRACE' + 'GET', 'HEAD', 'OPTIONS', 'TRACE', 'POST', 'PATCH' ]) const cache = opts.disableCache ? undefined : lru(opts.cacheURLs || 100) @@ -60,6 +60,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 +144,7 @@ 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) + requestImpl = createRequestRetry(request, this, retriesCount, retryOnError, maxRetriesOn503, customRetry) } else { requestImpl = request } @@ -251,29 +252,51 @@ 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, retriesCount, retryOnError, maxRetriesOn503, customRetry) { 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'] + const defaultRetryAfter = () => { + let retryAfter = 42 * Math.random() * (retries + 1) + + if (res && res.headers['retry-after']) { + retryAfter = res.headers['retry-after'] + } + return retryAfter } - if (!reply.sent) { - // always retry on 503 errors + + const defaultRetry = () => { if (res && res.statusCode === 503 && req.method === 'GET') { if (retriesCount === 0 && retries < maxRetriesOn503) { // we should stop at some point - return retry(retryAfter) + return true } } else if (retriesCount > retries && err && err.code === retryOnError) { - return retry(retryAfter) + return true + } + return false + } + + if (!reply.sent) { + if (customRetry && customRetry.handler) { + const retryAfter = customRetry.handler(req, res, defaultRetryAfter, defaultRetry) + if (retryAfter) { + const customRetries = customRetry.retries || 1 + if (++retries < customRetries) { + return retry(retryAfter) + } + } + } else { + if (defaultRetry()) { + return retry(defaultRetryAfter()) + } } } + 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..5ccf7645 --- /dev/null +++ b/test/retry-with-a-custom-handler.test.js @@ -0,0 +1,117 @@ +'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}!`) + } + }) +} + +// -> a server 500's and we don't have a custom handler we should fail +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) + + try { + await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + } catch (error) { + t.ok(error instanceof got.RequestError, 'should throw RequestError') + t.end() + } +}) + +// -> a server 500's and we have a custom handler we should revive not +test("a server 500's with a custom handler and should revive", async (t) => { + const customRetryLogic = (req, res, registerDefaultRetry, defaultRetryAfter) => { + 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!') +}) + +// -> server 503's with a custom handler not registering the default should ultimately fail +test("a server 503's with a custom handler for 500 but the custom handler never registers the default so should fail", async (t) => { + // the key here is we need our customRetryHandler doesn't register the deefault handler and as a result it doesn't work + const customRetryLogic = (req, res, registerDefaultRetry, defaultRetryAfter) => { + if (res && res.statusCode === 500 && req.method === 'GET') { + return 300 + } + return null + } + + const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }, 503) + + try { + await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + } catch (error) { + t.equal(error.message, 'Response code 503 (Service Unavailable)') + t.end() + } +}) + +test("a server 503's with a custom handler for 500 and the custom handler registers the default so it passes", async (t) => { + const customRetryLogic = (req, res, registerDefaultRetry, defaultRetryAfter) => { + // registering the default retry logic for non 500 errors if it occurs + if (registerDefaultRetry()) { + return defaultRetryAfter + } + + 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 6!') +}) diff --git a/types/index.test-d.ts b/types/index.test-d.ts index cb149562..facf2d50 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", @@ -41,7 +41,7 @@ const fullOptions: FastifyReplyFromOptions = { pipelining: 10 }, contentTypesToEncode: ['application/x-www-form-urlencoded'], - retryMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'], + retryMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'POST', 'PATCH'], maxRetriesOn503: 10, disableRequestLogging: false, globalAgent: false,