Skip to content

Commit

Permalink
Add retry.backoffLimit option (#454)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <[email protected]>
  • Loading branch information
fvanwijk and sindresorhus authored Dec 12, 2022
1 parent bc7d9f4 commit ab19baf
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 3 deletions.
8 changes: 6 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,17 @@ Default:
- `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) [`500`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) [`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`
- `backoffLimit`: `undefined`

An object representing `limit`, `methods`, `statusCodes` and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.

If `retry` is a number, it will be used as `limit` and other defaults will remain in place.

If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`. 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.

Delays between retries is calculated with the function `0.3 * (2 ** (retry - 1)) * 1000`, where `retry` is the attempt number (starts from 1).
The `backoffLimit` option is the upper limit of the delay per retry in milliseconds.
To clamp the delay, set `backoffLimit` to 1000, for example.
By default, the delay is calculated with `0.3 * (2 ** (attemptCount - 1)) * 1000`. The delay increases exponentially.

Retries are not triggered following a [timeout](#timeout).

Expand All @@ -188,7 +191,8 @@ const json = await ky('https://example.com', {
retry: {
limit: 10,
methods: ['get'],
statusCodes: [413]
statusCodes: [413],
backoffLimit: 3000
}
}).json();
```
Expand Down
2 changes: 1 addition & 1 deletion source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export class Ky {
}

const BACKOFF_FACTOR = 0.3;
return BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000;
return Math.min(this._options.retry.backoffLimit, BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000);
}

return 0;
Expand Down
16 changes: 16 additions & 0 deletions source/types/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,20 @@ export interface RetryOptions {
@default Infinity
*/
maxRetryAfter?: number;

/**
The upper limit of the delay per retry in milliseconds.
To clamp the delay, set `backoffLimit` to 1000, for example.
By default, the delay is calculated in the following way:
```
0.3 * (2 ** (attemptCount - 1)) * 1000
```
The delay increases exponentially.
@default Infinity
*/
backoffLimit?: number;
}
1 change: 1 addition & 0 deletions source/utils/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaultRetryOptions: Required<RetryOptions> = {
statusCodes: retryStatusCodes,
afterStatusCodes: retryAfterStatusCodes,
maxRetryAfter: Number.POSITIVE_INFINITY,
backoffLimit: Number.POSITIVE_INFINITY,
};

export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Required<RetryOptions> => {
Expand Down
59 changes: 59 additions & 0 deletions test/retry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {performance, PerformanceObserver} from 'node:perf_hooks';
import process from 'node:process';
import test from 'ava';
import ky from '../source/index.js';
import {createHttpTestServer} from './helpers/create-http-test-server.js';
Expand Down Expand Up @@ -440,3 +442,60 @@ test('throws when retry.statusCodes is not an array', async t => {

await server.close();
});

test('respect maximum backoff', async t => {
const retryCount = 5;
let requestCount = 0;

const server = await createHttpTestServer();
server.get('/', (_request, response) => {
requestCount++;

if (requestCount === retryCount) {
response.end(fixture);
} else {
response.sendStatus(500);
}
});

// We allow the test to take more time on CI than locally, to reduce flakiness
const allowedOffset = process.env.CI ? 1000 : 300;

// Register observer that asserts on duration when a measurement is performed
const obs = new PerformanceObserver(items => {
const measurements = items.getEntries();

const duration = measurements[0].duration ?? Number.NaN;
const expectedDuration = {default: 300 + 600 + 1200 + 2400, custom: 300 + 600 + 1000 + 1000}[measurements[0].name] ?? Number.NaN;

t.true(Math.abs(duration - expectedDuration) < allowedOffset, `Duration of ${duration}ms is not close to expected duration ${expectedDuration}ms`); // Allow for 300ms difference

if (measurements[0].name === 'custom') {
obs.disconnect();
}
});
obs.observe({entryTypes: ['measure']});

// Start measuring
performance.mark('start');
t.is(await ky(server.url, {
retry: retryCount,
}).text(), fixture);
performance.mark('end');

performance.mark('start-custom');
requestCount = 0;
t.is(await ky(server.url, {
retry: {
limit: retryCount,
backoffLimit: 1000,
},
}).text(), fixture);

performance.mark('end-custom');

performance.measure('default', 'start', 'end');
performance.measure('custom', 'start-custom', 'end-custom');

await server.close();
});

0 comments on commit ab19baf

Please sign in to comment.