From 5f3fbb9923330f96801740854ab07c46b0a7064a Mon Sep 17 00:00:00 2001 From: Mike Presman Date: Thu, 4 Jan 2024 03:59:56 -0500 Subject: [PATCH] add: error object to custom retry callback (#337) --- README.md | 31 +++++++-- index.js | 49 +++++++-------- test/retry-with-a-custom-handler.test.js | 80 ++++++++++++++++++------ types/index.d.ts | 39 +++++++----- types/index.test-d.ts | 9 +++ 5 files changed, 140 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index f63deeb8..62fd47c8 100644 --- a/README.md +++ b/README.md @@ -268,18 +268,24 @@ By Default: 10 --- -### `customRetry` +### `retryDelay` - `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 +This plugin gives the client an option to pass their own retry callback to allow the client to define what retryDelay they would like on any retries +outside the scope of what is handled by default in fastify-reply-from. To see the default please refer to index.js `getDefaultDelay()` +If a `handler` is passed to the `retryDelay` object the onus is on the client to invoke the default retry logic in their callback otherwise default cases such as 500 will not be handled + +- `err` is the error thrown by making a request using whichever agent is configured +- `req` is the raw request details sent to the underlying agent. __Note__: this object is not a Fastify request object, but instead the low-level request for the agent. +- `res` is the raw response returned by the underlying agent (if available) __Note__: this object is not a Fastify response, but instead the low-level response from the agent. This property may be null if no response was obtained at all, like from a connection reset or timeout. +- `attempt` in the object callback refers to the current retriesAttempt number. You are given the freedom to use this in concert with the retryCount property set to handle retries +- `getDefaultRetry` refers to the default retry handler. If this callback returns not null and you wish to handle those case of errors simply invoke it as done below. Given example ```js - const customRetryLogic = (req, res, getDefaultRetry) => { + const customRetryLogic = ({err, req, res, attempt, getDefaultRetry}) => { //If this block is not included all non 500 errors will not be retried const defaultDelay = getDefaultDelay(); if (defaultDelay) return defaultDelay(); @@ -288,6 +294,11 @@ Given example if (res && res.statusCode === 500 && req.method === 'GET') { return 300 } + + if (err && err.code == "UND_ERR_SOCKET"){ + return 600 + } + return null } @@ -295,11 +306,19 @@ Given example fastify.register(FastifyReplyFrom, { base: 'http://localhost:3001/', - customRetry: {handler: customRetryLogic, retries: 10} + retryDelay: customRetryLogic }) ``` +Note the Typescript Equivalent +``` +const customRetryLogic = ({req, res, err, getDefaultRetry}: RetryDetails) => { + ... +} +... + +``` --- ### `reply.from(source, [opts])` diff --git a/index.js b/index.js index 6c0c36e6..e607a923 100644 --- a/index.js +++ b/index.js @@ -59,7 +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 + const retryDelay = opts.retryDelay || undefined if (!source) { source = req.url @@ -142,38 +142,32 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) { const requestHeaders = rewriteRequestHeaders(this.request, headers) const contentLength = requestHeaders['content-length'] let requestImpl - if (retryMethods.has(method) && !contentLength) { - 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) + const getDefaultDelay = (req, res, err, retries) => { + if (retryMethods.has(method) && !contentLength) { + // 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) { + return retryAfter } + } else if (retriesCount > retries && err && err.code === retryOnError) { + return retryAfter } - return defaultDelay() } + return null + } - requestImpl = createRequestRetry(request, this, retryHandler) + if (retryDelay) { + requestImpl = createRequestRetry(request, this, (req, res, err, retries) => { + return retryDelay({ err, req, res, attempt: retries, getDefaultDelay }) + }) } else { - requestImpl = request + requestImpl = createRequestRetry(request, this, getDefaultDelay) } requestImpl({ method, url, qs, headers: requestHeaders, body }, (err, res) => { @@ -228,7 +222,6 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) { // actually destroy those sockets setImmediate(next) }) - next() }, { fastify: '4.x', diff --git a/test/retry-with-a-custom-handler.test.js b/test/retry-with-a-custom-handler.test.js index 23b4ab32..a56399f4 100644 --- a/test/retry-with-a-custom-handler.test.js +++ b/test/retry-with-a-custom-handler.test.js @@ -6,10 +6,11 @@ const From = require('..') const http = require('node:http') const got = require('got') -function serverWithCustomError (stopAfter, statusCodeToFailOn) { +function serverWithCustomError (stopAfter, statusCodeToFailOn, closeSocket) { let requestCount = 0 return http.createServer((req, res) => { if (requestCount++ < stopAfter) { + if (closeSocket) req.socket.end() res.statusCode = statusCodeToFailOn res.setHeader('Content-Type', 'text/plain') return res.end('This Service is Unavailable') @@ -21,8 +22,9 @@ function serverWithCustomError (stopAfter, statusCodeToFailOn) { }) } -async function setupServer (t, fromOptions = {}, statusCodeToFailOn = 500, stopAfter = 4) { - const target = serverWithCustomError(stopAfter, statusCodeToFailOn) +async function setupServer (t, fromOptions = {}, statusCodeToFailOn = 500, stopAfter = 4, closeSocket = false) { + const target = serverWithCustomError(stopAfter, statusCodeToFailOn, closeSocket) + await target.listen({ port: 0 }) t.teardown(target.close.bind(target)) @@ -48,7 +50,7 @@ test('a 500 status code with no custom handler should fail', async (t) => { let errorMessage try { - await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + await got.get(`http://localhost:${instance.server.address().port}`, { retry: 3 }) } catch (error) { errorMessage = error.message } @@ -57,63 +59,103 @@ test('a 500 status code with no custom handler should fail', async (t) => { }) test("a server 500's with a custom handler and should revive", async (t) => { - const customRetryLogic = (req, res, getDefaultDelay) => { + const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => { const defaultDelay = getDefaultDelay() if (defaultDelay) return defaultDelay if (res && res.statusCode === 500 && req.method === 'GET') { - return 300 + return 0.1 } return null } - const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }) + const { instance } = await setupServer(t, { retryDelay: customRetryLogic }) - const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 5 }) 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) => { +test('custom retry does not invoke the default delay causing a 501', async (t) => { + // the key here is our retryDelay doesn't register the deefault handler and as a result it doesn't work + const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => { if (res && res.statusCode === 500 && req.method === 'GET') { - return 300 + return 0 } return null } - const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }, 503) + const { instance } = await setupServer(t, { retryDelay: customRetryLogic }, 501) let errorMessage try { - await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + await got.get(`http://localhost:${instance.server.address().port}`, { retry: 5 }) } catch (error) { errorMessage = error.message } - t.equal(errorMessage, 'Response code 503 (Service Unavailable)') + t.equal(errorMessage, 'Response code 501 (Not Implemented)') }) test('custom retry delay functions can invoke the default delay', async (t) => { - const customRetryLogic = (req, res, getDefaultDelay) => { + const customRetryLogic = ({ req, res, err, attempt, 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 0.1 + } + + return null + } + + const { instance } = await setupServer(t, { retryDelay: customRetryLogic }, 500) + + const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 5 }) + + t.equal(res.headers['content-type'], 'text/plain') + t.equal(res.statusCode, 205) + t.equal(res.body.toString(), 'Hello World 5!') +}) + +test('custom retry delay function inspects the err paramater', async (t) => { + const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => { + if (err && (err.code === 'UND_ERR_SOCKET' || err.code === 'ECONNRESET')) { + return 0.1 + } + return null + } + + const { instance } = await setupServer(t, { retryDelay: customRetryLogic }, 500, 4, true) + + const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 5 }) + + t.equal(res.headers['content-type'], 'text/plain') + t.equal(res.statusCode, 205) + t.equal(res.body.toString(), 'Hello World 5!') +}) + +test('we can exceed our retryCount and introspect attempts independently', async (t) => { + const attemptCounter = [] + + const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => { + attemptCounter.push(attempt) + + if (err && (err.code === 'UND_ERR_SOCKET' || err.code === 'ECONNRESET')) { + return 0.1 } return null } - const { instance } = await setupServer(t, { customRetry: { handler: customRetryLogic, retries: 10 } }, 503) + const { instance } = await setupServer(t, { retryDelay: customRetryLogic }, 500, 4, true) - const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 0 }) + const res = await got.get(`http://localhost:${instance.server.address().port}`, { retry: 5 }) + t.match(attemptCounter, [0, 1, 2, 3, 4]) 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.d.ts b/types/index.d.ts index 09daef20..2effadaf 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,33 +1,33 @@ /// import { - FastifyRequest, + FastifyPluginCallback, FastifyReply, + FastifyRequest, + HTTPMethods, RawReplyDefaultExpression, RawServerBase, RequestGenericInterface, - HTTPMethods, - FastifyPluginCallback, } from 'fastify'; import { + Agent, + AgentOptions, IncomingHttpHeaders, RequestOptions, - AgentOptions, - Agent, } from "http"; import { - RequestOptions as SecureRequestOptions, - AgentOptions as SecureAgentOptions, - Agent as SecureAgent -} from "https"; -import { - IncomingHttpHeaders as Http2IncomingHttpHeaders, - ClientSessionRequestOptions, ClientSessionOptions, + ClientSessionRequestOptions, + IncomingHttpHeaders as Http2IncomingHttpHeaders, SecureClientSessionOptions, } from "http2"; -import { Pool } from 'undici' +import { + Agent as SecureAgent, + AgentOptions as SecureAgentOptions, + RequestOptions as SecureRequestOptions +} from "https"; +import { Pool } from 'undici'; declare module "fastify" { interface FastifyReply { @@ -39,12 +39,21 @@ declare module "fastify" { } type FastifyReplyFrom = FastifyPluginCallback - declare namespace fastifyReplyFrom { type QueryStringFunction = (search: string | undefined, reqUrl: string) => string; + + export type RetryDetails = { + err: Error; + req: FastifyRequest; + res: FastifyReply; + attempt: number; + getDefaultDelay: () => number | null; + } export interface FastifyReplyFromHooks { queryString?: { [key: string]: unknown } | QueryStringFunction; contentType?: string; + retryDelay?: (details: RetryDetails) => {} | null; + retriesCount?: number; onResponse?: ( request: FastifyRequest, reply: FastifyReply, @@ -99,7 +108,7 @@ declare namespace fastifyReplyFrom { } export const fastifyReplyFrom: FastifyReplyFrom - export { fastifyReplyFrom as default } + export { fastifyReplyFrom as default }; } declare function fastifyReplyFrom(...params: Parameters): ReturnType diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 0330f967..edd96696 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -91,6 +91,15 @@ async function main() { instance.get("/http2", (request, reply) => { reply.from("/", { method: "POST", + retryDelay: ({err, req, res, attempt, getDefaultDelay}) => { + const defaultDelay = getDefaultDelay(); + if (defaultDelay) return defaultDelay; + + if (res && res.statusCode === 500 && req.method === "GET") { + return 300; + } + return null; + }, rewriteHeaders(headers, req) { return headers; },