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

add: error object to custom retry callback #337

Merged
merged 14 commits into from
Jan 4, 2024
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -288,18 +294,31 @@ Given example
if (res && res.statusCode === 500 && req.method === 'GET') {
return 300
}

if (err && err.code == "UND_ERR_SOCKET"){
return 600
}

return null
}

.......

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])`
Expand Down
49 changes: 21 additions & 28 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -228,7 +222,6 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
// actually destroy those sockets
setImmediate(next)
})

next()
}, {
fastify: '4.x',
Expand Down
80 changes: 61 additions & 19 deletions test/retry-with-a-custom-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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))

Expand All @@ -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
}
Expand All @@ -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)
MikePresman marked this conversation as resolved.
Show resolved Hide resolved
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!')
Expand Down
39 changes: 24 additions & 15 deletions types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
/// <reference types="node" />

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 {
Expand All @@ -39,12 +39,21 @@ declare module "fastify" {
}

type FastifyReplyFrom = FastifyPluginCallback<fastifyReplyFrom.FastifyReplyFromOptions>

declare namespace fastifyReplyFrom {
type QueryStringFunction = (search: string | undefined, reqUrl: string) => string;

export type RetryDetails = {
err: Error;
req: FastifyRequest<RequestGenericInterface, RawServerBase>;
res: FastifyReply<RawServerBase>;
attempt: number;
getDefaultDelay: () => number | null;
}
export interface FastifyReplyFromHooks {
queryString?: { [key: string]: unknown } | QueryStringFunction;
contentType?: string;
retryDelay?: (details: RetryDetails) => {} | null;
retriesCount?: number;
onResponse?: (
request: FastifyRequest<RequestGenericInterface, RawServerBase>,
reply: FastifyReply<RawServerBase>,
Expand Down Expand Up @@ -99,7 +108,7 @@ declare namespace fastifyReplyFrom {
}

export const fastifyReplyFrom: FastifyReplyFrom
export { fastifyReplyFrom as default }
export { fastifyReplyFrom as default };
}

declare function fastifyReplyFrom(...params: Parameters<FastifyReplyFrom>): ReturnType<FastifyReplyFrom>
Expand Down
9 changes: 9 additions & 0 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down