Skip to content

Commit

Permalink
Add retry.delay option to control the time between retries (#533)
Browse files Browse the repository at this point in the history
  • Loading branch information
rclarey committed Oct 17, 2023
1 parent 577dd4c commit c6181ef
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 35 deletions.
3 changes: 3 additions & 0 deletions readme.md
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
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
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
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
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
@@ -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
@@ -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();
});

0 comments on commit c6181ef

Please sign in to comment.