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

feat(client): add support for global retry configuration #380

Merged
merged 2 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
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
74 changes: 41 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,58 +433,66 @@ For requests from the server, the config object is simply passed into the servic

## Retry

You can set Fetchr to retry failed requests automatically by setting a `retry` settings in the client configuration:
You can set Fetchr to automatically retry failed requests by specifying a `retry` configuration in the global or in the request configuration:

```js
fetcher
// Globally
const fetchr = new Fetchr({
retry: { maxRetries: 2 },
});

// Per request
fetchr
.read('service')
.clientConfig({
retry: {
maxRetries: 2,
},
retry: { maxRetries: 1 },
})
.end();
```

With this configuration, Fetchr will retry all requests that fail with 408 status code or that failed without even reaching the service (status code 0 means, for example, that the client was not able to reach the server) two more times before returning an error. The interval between each request respects
the following formula, based on the exponential backoff and full jitter strategy published in [this AWS architecture blog post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/):
With the above configuration, Fetchr will retry twice all requests
that fail but only once when calling `read('service')`.

You can further customize how the retry mechanism works. These are all
settings and their default values:

```js
Math.random() * Math.pow(2, attempt) * interval;
const fetchr = new Fetchr({
retry: {
maxRetries: 2, // amount of retries after the first failed request
interval: 200, // maximum interval between each request in ms (see note below)
statusCodes: [0, 408], // response status code that triggers a retry (see note below)
},
unsafeAllowRetry: false, // allow unsafe operations to be retried (see note below)
}
```

`attempt` is the number of the current retry attempt starting
from 0. By default `interval` corresponds to 200ms.
**interval**

You can customize the retry behavior by adding more properties in the
`retry` object:
The interval between each request respects the following formula, based on the exponential backoff and full jitter strategy published in [this AWS architecture blog post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/):

```js
fetcher
.read('resource')
.clientConfig({
retry: {
maxRetries: 5,
interval: 1000,
statusCodes: [408, 502],
},
})
.end();
Math.random() * Math.pow(2, attempt) * interval;
```

With the above configuration, Fetchr will retry all failed (408 or 502 status code) requests for a maximum of 5 times. The interval between each request will still use the formula from above, but the interval of 1000ms will be used instead.
`attempt` is the number of the current retry attempt starting
from 0. By default `interval` corresponds to 200ms.

**Note:** Fetchr doesn't retry POST requests for safety reasons. You can enable retries for POST requests by setting the `unsafeAllowRetry` property to `true`:
**statusCodes**

```js
fetcher
.create('resource')
.clientConfig({
retry: { maxRetries: 2 },
unsafeAllowRetry: true,
})
.end();
```
For historical reasons, fetchr only retries 408 responses and no
responses at all (for example, a network error, indicated by a status
code 0). However, you might find useful to also retry on other codes
as well (502, 503, 504 can be good candidates for an automatic
retries).

**unsafeAllowRetry**

By default, Fetchr only retries `read` requests. This is done for
safety reasons: reading twice an entry from a database is not as bad
as creating an entry twice. But if your application or resource
doesn't need this kind of protection, you can allow retries by setting
`unsafeAllowRetry` to `true` and fetchr will retry all operations.

## Context Variables

Expand Down
2 changes: 2 additions & 0 deletions libs/fetcher.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ function Fetcher(options) {
corsPath: options.corsPath,
context: options.context || {},
contextPicker: options.contextPicker || {},
retry: options.retry || null,
statsCollector: options.statsCollector,
unsafeAllowRetry: Boolean(options.unsafeAllowRetry),
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is called unsafe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the unsafeAllowRetry allows POST requests to be retried. In many cases, however, one would not like to have this feature enabled (since it could lead to duplicated transactions in the back-end). For me, a better name would be retryOnPost or something like this so it's clear what is happening (or, perhaps, removing this flag completely since, right now, retry is disabled by default).

Regarding the documentation, it's already in the readme:

**Note:** Fetchr doesn't retry POST requests for safety reasons. You can enable retries for POST requests by setting the `unsafeAllowRetry` property to `true`:

_serviceMeta: this._serviceMeta,
};
}
Expand Down
33 changes: 16 additions & 17 deletions libs/util/normalizeOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ function requestToOptions(request) {

var config = Object.assign(
{
unsafeAllowRetry: request.operation === 'read',
xhrTimeout: request.options.xhrTimeout,
},
request._clientConfig
Expand Down Expand Up @@ -83,24 +82,24 @@ function normalizeHeaders(options) {
return headers;
}

function normalizeRetry(options) {
var retry = {
interval: 200,
maxRetries: 0,
retryOnPost: false,
statusCodes: [0, 408, 999],
};

if (!options.config.retry) {
return retry;
}
function normalizeRetry(request) {
var retry = Object.assign(
{
interval: 200,
maxRetries: 0,
retryOnPost:
request.operation === 'read' ||
request.options.unsafeAllowRetry,
statusCodes: [0, 408, 999],
},
request.options.retry,
request._clientConfig.retry
);

if (options.config.unsafeAllowRetry) {
retry.retryOnPost = true;
if ('unsafeAllowRetry' in request._clientConfig) {
retry.retryOnPost = request._clientConfig.unsafeAllowRetry;
}

Object.assign(retry, options.config.retry);

if (retry.max_retries) {
console.warn(
'"max_retries" is deprecated and will be removed in a future release, use "maxRetries" instead.'
Expand All @@ -118,7 +117,7 @@ function normalizeOptions(request) {
body: options.data != null ? JSON.stringify(options.data) : undefined,
headers: normalizeHeaders(options),
method: options.method,
retry: normalizeRetry(options),
retry: normalizeRetry(request),
timeout: options.config.timeout || options.config.xhrTimeout,
url: options.url,
};
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/libs/fetcher.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,4 +649,83 @@ describe('Client Fetcher', function () {
});
});
});

describe('Custom retry', function () {
describe('should be configurable globally', function () {
before(function () {
mockery.registerMock('./util/httpRequest', function (options) {
expect(options.retry).to.deep.equal({
interval: 350,
maxRetries: 2,
retryOnPost: true,
statusCodes: [0, 502, 504],
});
return httpRequest(options);
});
mockery.enable({
useCleanCache: true,
warnOnUnregistered: false,
});

Fetcher = require('../../../libs/fetcher.client');

this.fetcher = new Fetcher({
retry: {
interval: 350,
maxRetries: 2,
statusCodes: [0, 502, 504],
},
unsafeAllowRetry: true,
});
});

testCrud(params, body, config, callback, resolve, reject);

after(function () {
mockery.deregisterMock('./util/httpRequest');
mockery.disable();
});
});

describe('should be configurable per request', function () {
before(function () {
mockery.registerMock('./util/httpRequest', function (options) {
expect(options.retry).to.deep.equal({
interval: 350,
maxRetries: 2,
retryOnPost: true,
statusCodes: [0, 502, 504],
});
return httpRequest(options);
});
mockery.enable({
useCleanCache: true,
warnOnUnregistered: false,
});
Fetcher = require('../../../libs/fetcher.client');
this.fetcher = new Fetcher({});
});
var customConfig = {
retry: {
interval: 350,
maxRetries: 2,
statusCodes: [0, 502, 504],
},
unsafeAllowRetry: true,
};
testCrud({
disableNoConfigTests: true,
params: params,
body: body,
config: customConfig,
callback: callback,
resolve: resolve,
reject: reject,
});
after(function () {
mockery.deregisterMock('./util/httpRequest');
mockery.disable();
});
});
});
});