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

Add retry.backoffLimit option #454

Merged
merged 14 commits into from
Dec 12, 2022
8 changes: 6 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,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 @@ -187,7 +190,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 @@ -226,7 +226,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.
fvanwijk marked this conversation as resolved.
Show resolved Hide resolved
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;
fvanwijk marked this conversation as resolved.
Show resolved Hide resolved
}
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();
});