From cd21c040a8365537dd9c80dc4a586f19cbb31324 Mon Sep 17 00:00:00 2001 From: Pablo Palacios Date: Fri, 6 May 2022 04:19:32 +0200 Subject: [PATCH] feat(client): add support for global retry configuration (#380) --- README.md | 74 ++++++++++++++++------------- libs/fetcher.client.js | 2 + libs/util/normalizeOptions.js | 33 +++++++------ tests/unit/libs/fetcher.client.js | 79 +++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index f5f5381..19db45e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/libs/fetcher.client.js b/libs/fetcher.client.js index c3715e8..2e87c0c 100644 --- a/libs/fetcher.client.js +++ b/libs/fetcher.client.js @@ -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), _serviceMeta: this._serviceMeta, }; } diff --git a/libs/util/normalizeOptions.js b/libs/util/normalizeOptions.js index d8ebb88..7c3a885 100644 --- a/libs/util/normalizeOptions.js +++ b/libs/util/normalizeOptions.js @@ -8,7 +8,6 @@ function requestToOptions(request) { var config = Object.assign( { - unsafeAllowRetry: request.operation === 'read', xhrTimeout: request.options.xhrTimeout, }, request._clientConfig @@ -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.' @@ -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, }; diff --git a/tests/unit/libs/fetcher.client.js b/tests/unit/libs/fetcher.client.js index 759ca91..76ed8c5 100644 --- a/tests/unit/libs/fetcher.client.js +++ b/tests/unit/libs/fetcher.client.js @@ -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(); + }); + }); + }); });