Skip to content

Commit

Permalink
add: custom retry behaviour + more retryMethods
Browse files Browse the repository at this point in the history
  • Loading branch information
MikePresman committed Nov 6, 2023
1 parent 5d744b3 commit 12969ad
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 32 deletions.
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
}

.......

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'
])

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) {
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)
})
}
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) => {
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

0 comments on commit 12969ad

Please sign in to comment.