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.delay option to control the time between retries #533

Merged
merged 6 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Default:
- `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`
- `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000`

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.

Expand All @@ -207,6 +208,8 @@ The `backoffLimit` option is the upper limit of the delay per retry in milliseco
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.

The `delay` option can be used to change how the delay between retries is calculated. The function receives one parameter, the attempt count, starting at `1`.

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

```js
Expand Down
4 changes: 2 additions & 2 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ export class Ky {
}
}

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

return 0;
Expand Down
2 changes: 1 addition & 1 deletion source/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export interface Options extends Omit<RequestInit, 'headers'> { // eslint-disabl

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).
By default, delays between retries are calculated with the function `0.3 * (2 ** (attemptCount - 1)) * 1000`, where `attemptCount` is the attempt number (starts from 1), however this can be changed by passing a `delay` function.

Retries are not triggered following a timeout.

Expand Down
7 changes: 7 additions & 0 deletions source/types/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,11 @@ export type RetryOptions = {
@default Infinity
*/
backoffLimit?: number;

/**
A function to calculate the delay between retries given `attemptCount` (starts from 1).

@default attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000
*/
delay?: (attemptCount: number) => number;
};
1 change: 1 addition & 0 deletions source/utils/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const defaultRetryOptions: Required<RetryOptions> = {
afterStatusCodes: retryAfterStatusCodes,
maxRetryAfter: Number.POSITIVE_INFINITY,
backoffLimit: Number.POSITIVE_INFINITY,
delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000,
};

export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Required<RetryOptions> => {
Expand Down
42 changes: 42 additions & 0 deletions test/helpers/with-performance-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {performance, PerformanceObserver} from 'node:perf_hooks';
import process from 'node:process';
import type {ExecutionContext} from 'ava';

type Arg = {
name: string;
expectedDuration: number;
t: ExecutionContext;
test: () => Promise<void>;
};

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

export async function withPerformanceObserver({
name,
expectedDuration,
t,
test,
}: Arg) {
// 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;

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

obs.disconnect();
});
obs.observe({entryTypes: ['measure']});

// Start measuring
performance.mark(`start-${name}`);
await test();
performance.mark(`end-${name}`);

performance.measure(name, `start-${name}`, `end-${name}`);
}
82 changes: 50 additions & 32 deletions test/retry.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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';
import {withPerformanceObserver} from './helpers/with-performance-observer.js';

const fixture = 'fixture';
const defaultRetryCount = 2;
Expand Down Expand Up @@ -458,44 +457,63 @@ test('respect maximum backoff', async t => {
}
});

// We allow the test to take more time on CI than locally, to reduce flakiness
const allowedOffset = process.env.CI ? 1000 : 300;
await withPerformanceObserver({
t,
name: 'default',
expectedDuration: 300 + 600 + 1200 + 2400,
async test() {
t.is(await ky(server.url, {
retry: retryCount,
}).text(), fixture);
},
});

// Register observer that asserts on duration when a measurement is performed
const obs = new PerformanceObserver(items => {
const measurements = items.getEntries();
requestCount = 0;
await withPerformanceObserver({
t,
name: 'custom',
expectedDuration: 300 + 600 + 1000 + 1000,
async test() {
t.is(await ky(server.url, {
retry: {
limit: retryCount,
backoffLimit: 1000,
},
}).text(), fixture);
},
});

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

t.true(Math.abs(duration - expectedDuration) < allowedOffset, `Duration of ${duration}ms is not close to expected duration ${expectedDuration}ms`); // Allow for 300ms difference
test('respect custom retry.delay', async t => {
const retryCount = 5;
let requestCount = 0;

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

if (measurements[0].name === 'custom') {
obs.disconnect();
if (requestCount === retryCount) {
response.end(fixture);
} else {
response.sendStatus(500);
}
});
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,
await withPerformanceObserver({
t,
name: 'linear',
expectedDuration: 200 + 300 + 400 + 500,
async test() {
t.is(await ky(server.url, {
retry: {
limit: retryCount,
delay: n => 100 * (n + 1),
},
}).text(), fixture);
},
}).text(), fixture);

performance.mark('end-custom');

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

await server.close();
});
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved