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

feat(retry): allow respecting Retry-After for user-defined status codes #598

5 changes: 4 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,17 @@ Default:
- `limit`: `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) [`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)
- `afterStatusCodes`: [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413), [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503)
- `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.
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, 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 the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date or timeout given in the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header has passed to retry the request. If the provided status code is not in the list, the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header will be ignored.

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 use `maxRetryAfter`.

The `backoffLimit` option is the upper limit of the delay per retry in milliseconds.
Expand Down
4 changes: 3 additions & 1 deletion source/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ export type KyOptions = {
prefixUrl?: URL | string;

/**
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.
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, 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 the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date or timeout given in the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header has passed to retry the request. If the provided status code is not in the list, the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header will be ignored.

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.

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.
Expand Down
1 change: 0 additions & 1 deletion source/utils/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,5 @@ export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Requir
return {
...defaultRetryOptions,
...retry,
afterStatusCodes: retryAfterStatusCodes,
};
};
50 changes: 41 additions & 9 deletions test/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {withPerformance} from './helpers/with-performance.js';

const fixture = 'fixture';
const defaultRetryCount = 2;
const retryAfterOn500 = 2;
const retryAfterOn413 = 2;
const lastTried413access = Date.now();

test('network error', async t => {
let requestCount = 0;
Expand All @@ -23,6 +23,7 @@ test('network error', async t => {
});

t.is(await ky(server.url).text(), fixture);
t.is(requestCount, defaultRetryCount + 1);

await server.close();
});
Expand All @@ -42,6 +43,7 @@ test('status code 500', async t => {
});

t.is(await ky(server.url).text(), fixture);
t.is(requestCount, defaultRetryCount + 1);

await server.close();
});
Expand All @@ -61,6 +63,7 @@ test('only on defined status codes', async t => {
});

await t.throwsAsync(ky(server.url).text(), {message: /Bad Request/});
t.is(requestCount, 1);

await server.close();
});
Expand All @@ -82,6 +85,7 @@ test('not on POST', async t => {
await t.throwsAsync(ky.post(server.url).text(), {
message: /Internal Server Error/,
});
t.is(requestCount, 1);

await server.close();
});
Expand Down Expand Up @@ -121,14 +125,15 @@ test('respect Retry-After: 0 and retry immediately', async t => {
});

test('respect 413 Retry-After', async t => {
const startTime = Date.now();
let requestCount = 0;

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

if (requestCount === defaultRetryCount + 1) {
response.end((Date.now() - lastTried413access).toString());
response.end((Date.now() - startTime).toString());
} else {
response.writeHead(413, {
'Retry-After': retryAfterOn413,
Expand All @@ -137,20 +142,22 @@ test('respect 413 Retry-After', async t => {
}
});

const result = await ky(server.url).text();
t.true(Number(result) >= retryAfterOn413 * 1000);
const timeElapsedInMs = Number(await ky(server.url).text());
t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
t.is(requestCount, retryAfterOn413 + 1);

await server.close();
});

test('respect 413 Retry-After with timestamp', async t => {
const startTime = Date.now();
let requestCount = 0;

const server = await createHttpTestServer({bodyParser: false});
server.get('/', (_request, response) => {
requestCount++;
if (requestCount === defaultRetryCount + 1) {
response.end((Date.now() - lastTried413access).toString());
response.end((Date.now() - startTime).toString());
} else {
// @NOTE we need to round up to the next second due to http-date resolution
const date = new Date(Date.now() + ((retryAfterOn413 + 1) * 1000)).toUTCString();
Expand All @@ -161,9 +168,9 @@ test('respect 413 Retry-After with timestamp', async t => {
}
});

const result = await ky(server.url).text();
t.true(Number(result) >= retryAfterOn413 * 1000);
t.is(requestCount, 3);
const timeElapsedInMs = Number(await ky(server.url).text());
t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
t.is(requestCount, retryAfterOn413 + 1);

await server.close();
});
Expand All @@ -185,6 +192,31 @@ test('doesn\'t retry on 413 without Retry-After header', async t => {
await server.close();
});

test('respect custom `afterStatusCodes` (500) with Retry-After header', async t => {
const startTime = Date.now();
let requestCount = 0;

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

if (requestCount === defaultRetryCount + 1) {
response.end((Date.now() - startTime).toString());
} else {
response.writeHead(500, {
'Retry-After': retryAfterOn500,
});
response.end('');
}
});

const timeElapsedInMs = Number(await ky(server.url, {retry: {afterStatusCodes: [500]}}).text());
t.true(timeElapsedInMs >= retryAfterOn500 * 1000);
t.is(requestCount, retryAfterOn500 + 1);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing an assertion for the requestCount.

Looks like some of the other retry tests also have this mistake.

await server.close();
});

test('respect number of retries', async t => {
let requestCount = 0;

Expand Down Expand Up @@ -249,7 +281,7 @@ test('respect retry methods', async t => {
message: /Request Timeout/,
},
);
t.is(requestCount, 3);
t.is(requestCount, defaultRetryCount + 1);

await server.close();
});
Expand Down