diff --git a/index.js b/index.js index 9f9a398..75818e4 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const debug = require('debug')('fetch-retry'); // retry settings const MIN_TIMEOUT = 10; const MAX_RETRIES = 5; +const MAX_RETRY_AFTER = 20; const FACTOR = 6; module.exports = exports = setup; @@ -20,6 +21,7 @@ function setup(fetch) { minTimeout: MIN_TIMEOUT, retries: MAX_RETRIES, factor: FACTOR, + maxRetryAfter: MAX_RETRY_AFTER, }, opts.retry); if (opts.onRetry) { @@ -38,7 +40,16 @@ function setup(fetch) { // this will be retried const res = await fetch(url, opts); debug('status %d', res.status); - if (res.status >= 500 && res.status < 600) { + if ((res.status >= 500 && res.status < 600) || res.status === 429) { + // NOTE: doesn't support http-date format + const retryAfter = parseInt(res.headers.get('retry-after'), 10); + if (retryAfter) { + if (retryAfter > retryOpts.maxRetryAfter) { + return res; + } else { + await new Promise(r => setTimeout(r, retryAfter * 1e3)); + } + } throw new ResponseError(res); } else { return res; diff --git a/readme.md b/readme.md index 907600d..0367ffa 100644 --- a/readme.md +++ b/readme.md @@ -19,9 +19,14 @@ module.exports = async () => { Make sure to `yarn add @zeit/fetch-retry` in your main package. Note that you can pass [retry options](https://github.com/zeit/async-retry) to using `opts.retry`. -We also provide a `opts.onRetry` which is a customized version of `opts.retry.onRetry` and passes +We also provide a `opts.onRetry` and `opts.retry.maxRetryAfter` options. + +`opts.onRetry` is a customized version of `opts.retry.onRetry` and passes not only the `error` object in each retry but also the current `opts` object. +`opts.retry.maxRetryAfter` is the max wait time according to the `Retry-After` header. +If it exceeds the option value, stop retrying and returns the error response. It defaults to `20`. + ## Rationale Some errors are very common in production (like the underlying `Socket` @@ -30,7 +35,7 @@ by retrying. The default behavior of `fetch-retry` is to attempt retries **10**, **60** **360**, **2160** and **12960** milliseconds (a total of 5 retries) after -a *network error* or *5xx* error occur. +a *network error*, *429* or *5xx* error occur. The idea is to provide a sensible default: most applications should continue to perform correctly with a worst case scenario of a given diff --git a/test.js b/test.js index c7cf577..53dbbac 100644 --- a/test.js +++ b/test.js @@ -87,3 +87,61 @@ test('accepts a custom onRetry option', async () => { server.on('error', reject); }); }) + +test('handles the Retry-After header', async () => { + const server = createServer((req, res) => { + res.writeHead(429, { 'Retry-After': 1 }); + res.end(); + }); + + return new Promise((resolve, reject) => { + server.listen(async () => { + const {port} = server.address(); + try { + const startedAt = Date.now(); + const res = await retryFetch(`http://127.0.0.1:${port}`, { + retry: { + minTimeout: 10, + retries: 1 + } + }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1010); + resolve(); + } catch (err) { + reject(err); + } finally { + server.close(); + } + }); + server.on('error', reject); + }); +}); + +test('stops retrying when the Retry-After header exceeds the maxRetryAfter option', async () => { + const server = createServer((req, res) => { + res.writeHead(429, { 'Retry-After': 21 }); + res.end(); + }); + + return new Promise((resolve, reject) => { + const opts = { + onRetry: jest.fn(), + } + + server.listen(async () => { + const {port} = server.address(); + try { + const startedAt = Date.now(); + const res = await retryFetch(`http://127.0.0.1:${port}`, opts); + expect(opts.onRetry.mock.calls.length).toBe(0); + expect(res.status).toBe(429); + resolve(); + } catch (err) { + reject(err); + } finally { + server.close(); + } + }); + server.on('error', reject); + }); +});