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

[feature] custom retry callback #332

Merged
merged 6 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 46 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand All @@ -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: {
}
})
Expand All @@ -264,6 +264,37 @@ 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, 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
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be clearer not to describe this as "registering" another retry handler (both in this PR and in the test code), we're not really adding a handler to an event emitter or something, but just passing an option.

Also, I think that a custom retry handler needs to be passed the number of attempts so far if it wants to calculate an exponential delay. What do you think about this API instaead

Suggested change
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
}
const customRetryLogic = (req, res, attemptCount, getDefaultDelay) => {
const defaultDelay = getDefaultDelay();
if (defaultDelay) return defaultDelay();
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])`
Expand Down Expand Up @@ -341,13 +372,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);
```

Expand All @@ -359,14 +390,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" }
});
```

Expand All @@ -390,12 +421,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)
Expand All @@ -407,7 +438,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']`


Expand Down
43 changes: 33 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes this a breaking change when it doesn't need to be. I think it'd be simpler if we just passed this in gadget side?

])

const cache = opts.disableCache ? undefined : lru(opts.cacheURLs || 100)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method signature to this function seems a little complicated. I think it might be simpler if the caller just built one "give me the retry delay" function, and passed it into this thing. That function can do whatever it needs to based on the options, and kind of bottles up all the logic, so this thing doesn't really need to care about the "when" of retrying, and is just about actually doing it based on the output of that function.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about including the 503 retry behaviour in this function as well? To me it doesn't really seem extra special such that a custom handler may want to be a client of that logic as well.


if (res && res.headers['retry-after']) {
retryAfter = res.headers['retry-after']
}
return retryAfter
}
if (!reply.sent) {
// always retry on 503 errors

const defaultRetry = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why two separate functions for the duration and if we should retry or not? I think it'd be simpler with one function that returns null or a number, null meaning don't retry, and a number meaning retry after that many milliseconds.

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)
})
}
Expand Down
114 changes: 114 additions & 0 deletions test/retry-with-a-custom-handler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'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)

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()
}
})

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!')
})

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 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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are down with the language change

Suggested change
test("a server 503's with a custom handler for 500 and the custom handler registers the default so it passes", async (t) => {
test("custom retry delay functions can invoke the default delay", 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!')
})
14 changes: 7 additions & 7 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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,
Expand Down