Skip to content

Commit

Permalink
Support retrying on some HTTP status codes and generally improve the …
Browse files Browse the repository at this point in the history
…retry functionality (#508)

Fixes #417 
Fixes #379
  • Loading branch information
szmarczak authored and sindresorhus committed Jul 12, 2018
1 parent 99e3835 commit 98b5664
Show file tree
Hide file tree
Showing 17 changed files with 332 additions and 71 deletions.
1 change: 0 additions & 1 deletion package.json
Expand Up @@ -40,7 +40,6 @@
"duplexer3": "^0.1.4",
"extend": "^3.0.1",
"get-stream": "^3.0.0",
"is-retry-allowed": "^1.1.0",
"mimic-response": "^1.0.0",
"p-cancelable": "^0.5.0",
"to-readable-stream": "^1.0.0",
Expand Down
27 changes: 21 additions & 6 deletions readme.md
Expand Up @@ -190,16 +190,31 @@ Milliseconds to wait for the server to end the response before aborting request

This also accepts an object with separate `connect`, `socket`, and `request` fields for connection, socket, and entire request timeouts.

###### retries
###### retry

Type: `number` `Function`<br>
Default: `2`
Type: `number` `Object`<br>
Default:
- retries: `2`
- methods: `GET` `PUT` `HEAD` `DELETE` `OPTIONS` `TRACE`
- statusCodes: [`408`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413) [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) [`502`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) [`504`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
- maxRetryAfter: `undefined`

Number of request retries when network errors happens. Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.random() * 100`, where `retry` is attempt number (starts from 0).
Object representing `retries`, `methods`, `statusCodes` and `maxRetryAfter` fields for time until retry, allowed methods, allowed status codes and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.

Option accepts `function` with `retry` and `error` arguments. Function must return delay in milliseconds (`0` return value cancels retry).
If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`.<br>
If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will cancel the request.

**Note:** if `retries` is `number`, `ENOTFOUND` and `ENETUNREACH` error will not be retried (see full list in [`is-retry-allowed`](https://github.com/floatdrop/is-retry-allowed/blob/master/index.js#L12) module).
Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.random() * 100`, where `retry` is attempt number (starts from 0).

Option `retries` can be a `number`, but also accepts a `function` with `retry` and `error` arguments. Function must return delay in milliseconds (`0` return value cancels retry).

**Note:** It retries only on the specified methods, status codes, and on these network errors:
- `ETIMEDOUT`: Connection was not estabilished after a period of time.
- `ECONNRESET`: Connection was forcibly closed by a peer.
- `EADDRINUSE`: Could not bind to any free port.
- `ESOCKETTIMEDOUT`: Connected, but received no response after a period of time.
- `ECONNREFUSED`: Connection was refused by the server.
- `EPIPE`: The remote side of the stream being written has been closed.

###### followRedirect

Expand Down
14 changes: 12 additions & 2 deletions source/as-promise.js
Expand Up @@ -67,10 +67,20 @@ module.exports = options => {
}
}

if (options.throwHttpErrors && statusCode !== 304 && (statusCode < 200 || statusCode > limitStatusCode)) {
if (statusCode !== 304 && (statusCode < 200 || statusCode > limitStatusCode)) {
const error = new HTTPError(statusCode, response.statusMessage, response.headers, options);
Object.defineProperty(error, 'response', {value: response});
reject(error);
emitter.emit('retry', error, retried => {
if (!retried) {
if (options.throwHttpErrors) {
reject(error);
return;
}

resolve(response);
}
});
return;
}

resolve(response);
Expand Down
2 changes: 2 additions & 0 deletions source/as-stream.js
Expand Up @@ -10,6 +10,8 @@ module.exports = options => {
const output = new PassThrough();
const proxy = duplexer3(input, output);

options.gotRetry.retries = () => 0;

if (options.json) {
throw new Error('Got can not be used as a stream when the `json` option is used');
}
Expand Down
11 changes: 11 additions & 0 deletions source/assign-options.js
Expand Up @@ -12,5 +12,16 @@ module.exports = (defaults, options = {}) => {
}
}

// Override these arrays because we don't want to extend them
if (is.object(options.retry)) {
if (Reflect.has(options.retry, 'methods')) {
opts.retry.methods = options.retry.methods;
}

if (Reflect.has(options.retry, 'statusCodes')) {
opts.retry.statusCodes = options.retry.statusCodes;
}
}

return opts;
};
5 changes: 3 additions & 2 deletions source/create.js
Expand Up @@ -7,14 +7,14 @@ const asPromise = require('./as-promise');
const normalizeArguments = require('./normalize-arguments');
const deepFreeze = require('./deep-freeze');

const next = (path, options) => {
const makeNext = defaults => (path, options) => {
let url = path;

if (options.baseUrl) {
url = new URLGlobal(path, options.baseUrl);
}

options = normalizeArguments(url, options);
options = normalizeArguments(url, options, defaults);

if (options.stream) {
return asStream(options);
Expand All @@ -24,6 +24,7 @@ const next = (path, options) => {
};

const create = defaults => {
const next = makeNext(defaults);
if (!defaults.handler) {
defaults.handler = next;
}
Expand Down
6 changes: 5 additions & 1 deletion source/index.js
Expand Up @@ -12,7 +12,11 @@ const defaults = {
'delete'
],
options: {
retries: 2,
retry: {
retries: 2,
methods: ['GET', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'],
statusCodes: [408, 413, 429, 502, 503, 504]
},
cache: false,
decompress: true,
useElectronNet: false,
Expand Down
18 changes: 18 additions & 0 deletions source/is-retry-on-network-error-allowed.js
@@ -0,0 +1,18 @@
'use strict';

const WHITELIST = new Set([
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ESOCKETTIMEDOUT',
'ECONNREFUSED',
'EPIPE'
]);

module.exports = err => {
if (err && WHITELIST.has(err.code)) {
return true;
}

return false;
};
60 changes: 53 additions & 7 deletions source/normalize-arguments.js
@@ -1,13 +1,15 @@
'use strict';
const URLSearchParamsGlobal = typeof URLSearchParams === 'undefined' ? require('url').URLSearchParams : URLSearchParams; // TODO: Use the `URL` global when targeting Node.js 10
const is = require('@sindresorhus/is');
const isRetryAllowed = require('is-retry-allowed');
const toReadableStream = require('to-readable-stream');
const urlParseLax = require('url-parse-lax');
const isRetryOnNetworkErrorAllowed = require('./is-retry-on-network-error-allowed');
const urlToOptions = require('./url-to-options');
const isFormData = require('./is-form-data');

module.exports = (url, options) => {
const RETRY_AFTER_STATUS_CODES = new Set([413, 429, 503]);

module.exports = (url, options, defaults) => {
if (Reflect.has(options, 'url') || (is.object(url) && Reflect.has(url, 'url'))) {
throw new TypeError('Parameter `url` is not an option. Use got(url, options)');
}
Expand Down Expand Up @@ -112,17 +114,61 @@ module.exports = (url, options) => {
}
}

if (!is.function(options.retries)) {
const {retries} = options;
options.gotRetry = {retries: 0, methods: [], statusCodes: []};
if (options.retry !== false) {
if (is.number(options.retry)) {
if (is.object(defaults.options.retry)) {
options.gotRetry = {...defaults.options.retry, retries: options.retry};
} else {
options.gotRetry.retries = options.retry;
}
} else {
options.gotRetry = {...options.gotRetry, ...options.retry};
}
delete options.retry;
}

options.gotRetry.methods = new Set(options.gotRetry.methods.map(method => method.toUpperCase()));
options.gotRetry.statusCodes = new Set(options.gotRetry.statusCodes);

if (!options.gotRetry.maxRetryAfter && Reflect.has(options, 'timeout')) {
if (is.number(options.timeout)) {
options.gotRetry.maxRetryAfter = options.timeout;
} else {
options.gotRetry.maxRetryAfter = Math.min(...[options.timeout.request, options.timeout.connection].filter(n => !is.nullOrUndefined(n)));
}
}

if (!is.function(options.gotRetry.retries)) {
const {retries} = options.gotRetry;

options.gotRetry.retries = (iteration, error) => {
if (iteration > retries || (!isRetryOnNetworkErrorAllowed(error) && (!options.gotRetry.methods.has(error.method) || !options.gotRetry.statusCodes.has(error.statusCode)))) {
return 0;
}

if (Reflect.has(error, 'headers') && Reflect.has(error.headers, 'retry-after') && RETRY_AFTER_STATUS_CODES.has(error.statusCode)) {
let after = Number(error.headers['retry-after']);
if (is.number(after)) {
after *= 1000;
} else {
after = Math.max(Date.parse(error.headers['retry-after']) - Date.now(), 0);
}

if (after > options.gotRetry.maxRetryAfter) {
return 0;
}

return after;
}

options.retries = (iter, error) => {
if (iter > retries || !isRetryAllowed(error)) {
if (error.statusCode === 413) {
return 0;
}

const noise = Math.random() * 100;

return ((1 << iter) * 1000) + noise;
return ((1 << iteration) * 1000) + noise;
};
}

Expand Down
41 changes: 27 additions & 14 deletions source/request-as-event-emitter.js
Expand Up @@ -20,6 +20,7 @@ module.exports = (options = {}) => {
const redirects = [];
const agents = is.object(options.agent) ? options.agent : null;
let retryCount = 0;
let retryTries = 0;
let redirectUrl;
let uploadBodySize;
let uploaded = 0;
Expand Down Expand Up @@ -56,6 +57,7 @@ module.exports = (options = {}) => {

const {statusCode} = response;

response.retryCount = retryCount;
response.url = redirectUrl || requestUrl;
response.requestUrl = requestUrl;

Expand Down Expand Up @@ -131,20 +133,12 @@ module.exports = (options = {}) => {
return;
}

const backoff = options.retries(++retryCount, error);

if (backoff) {
setTimeout(options => {
try {
get(options);
} catch (error2) {
emitter.emit('error', error2);
}
}, backoff, options);
return;
}

emitter.emit('error', new RequestError(error, options));
const err = new RequestError(error, options);
emitter.emit('retry', err, retried => {
if (!retried) {
emitter.emit('error', err);
}
});
});

emitter.once('request', req => {
Expand Down Expand Up @@ -212,6 +206,25 @@ module.exports = (options = {}) => {
});
};

emitter.on('retry', (error, cb) => {
const backoff = options.gotRetry.retries(++retryTries, error);

if (backoff) {
retryCount++;
setTimeout(options => {
try {
get(options);
} catch (error2) {
emitter.emit('error', error2);
}
}, backoff, options);
cb(true);
return;
}

cb(false);
});

setImmediate(async () => {
try {
uploadBodySize = await getBodySize(options);
Expand Down
3 changes: 1 addition & 2 deletions source/timed-out.js
Expand Up @@ -2,12 +2,11 @@

// Forked from https://github.com/floatdrop/timed-out

module.exports = function (req, time) {
module.exports = function (req, delays) {
if (req.timeoutTimer) {
return req;
}

const delays = isNaN(time) ? time : {request: time};
const host = req._headers ? (' to ' + req._headers.host) : '';

function throwESOCKETTIMEDOUT() {
Expand Down
6 changes: 4 additions & 2 deletions test/cancel.js
Expand Up @@ -42,8 +42,10 @@ test('cancel do not retry after cancelation', async t => {
const helper = await createAbortServer();

const p = got(helper.redirectUrl, {
retries: _ => {
t.fail('Makes a new try after cancelation');
retry: {
retries: _ => {
t.fail('Makes a new try after cancelation');
}
}
});

Expand Down
2 changes: 1 addition & 1 deletion test/error.js
Expand Up @@ -45,7 +45,7 @@ test('properties', async t => {
});

test('dns message', async t => {
const err = await t.throws(got('.com', {retries: 0}));
const err = await t.throws(got('.com', {retry: 0}));
t.truthy(err);
t.regex(err.message, /getaddrinfo ENOTFOUND/);
t.is(err.host, '.com');
Expand Down
2 changes: 1 addition & 1 deletion test/helpers.js
Expand Up @@ -25,7 +25,7 @@ test('promise mode', async t => {
const err = await t.throws(got.get(`${s.url}/404`));
t.is(err.response.body, 'not found');

const err2 = await t.throws(got.get('.com', {retries: 0}));
const err2 = await t.throws(got.get('.com', {retry: 0}));
t.truthy(err2);
});

Expand Down

0 comments on commit 98b5664

Please sign in to comment.