From 8764419e4ae23f1ed41d05d9be70f32808726a44 Mon Sep 17 00:00:00 2001 From: Kenneth Sills <132029135+Kenneth-Sills@users.noreply.github.com> Date: Thu, 25 Jul 2024 14:56:14 -0400 Subject: [PATCH] Add `retry.afterStatusCodes` option (#598) Co-authored-by: Seth Holladay --- readme.md | 5 +++- source/types/options.ts | 4 +++- source/utils/normalize.ts | 1 - test/retry.ts | 50 ++++++++++++++++++++++++++++++++------- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/readme.md b/readme.md index bc0ae976..220ec119 100644 --- a/readme.md +++ b/readme.md @@ -213,14 +213,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. diff --git a/source/types/options.ts b/source/types/options.ts index 22fd0651..bcdd15b7 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -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. diff --git a/source/utils/normalize.ts b/source/utils/normalize.ts index 8376f641..f641f792 100644 --- a/source/utils/normalize.ts +++ b/source/utils/normalize.ts @@ -40,6 +40,5 @@ export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Requir return { ...defaultRetryOptions, ...retry, - afterStatusCodes: retryAfterStatusCodes, }; }; diff --git a/test/retry.ts b/test/retry.ts index d44b1e1a..787525fe 100644 --- a/test/retry.ts +++ b/test/retry.ts @@ -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; @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -121,6 +125,7 @@ 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(); @@ -128,7 +133,7 @@ test('respect 413 Retry-After', async t => { requestCount++; if (requestCount === defaultRetryCount + 1) { - response.end((Date.now() - lastTried413access).toString()); + response.end((Date.now() - startTime).toString()); } else { response.writeHead(413, { 'Retry-After': retryAfterOn413, @@ -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(); @@ -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(); }); @@ -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); + + await server.close(); +}); + test('respect number of retries', async t => { let requestCount = 0; @@ -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(); });