From 8531b050918d54066abb9f11b0193b893e77718f Mon Sep 17 00:00:00 2001 From: Seth Holladay Date: Mon, 12 Aug 2024 19:27:37 -0400 Subject: [PATCH] Add support for the `RateLimit-Reset` header (#618) --- readme.md | 2 +- source/core/Ky.ts | 8 ++++- source/types/options.ts | 2 +- test/retry.ts | 65 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index 86045eca..3d8718d9 100644 --- a/readme.md +++ b/readme.md @@ -208,7 +208,7 @@ An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, an 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 the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date, timeout, or timestamp 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 `Retry-After` is missing, the non-standard [`RateLimit-Reset`](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-05.html#section-3.3) header is used in its place as a fallback. 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`. diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 218a9c22..00f35b13 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -217,11 +217,17 @@ export class Ky { throw error; } - const retryAfter = error.response.headers.get('Retry-After'); + const retryAfter = error.response.headers.get('Retry-After') + ?? error.response.headers.get('RateLimit-Reset') + ?? error.response.headers.get('X-RateLimit-Reset') // GitHub + ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) { let after = Number(retryAfter) * 1000; if (Number.isNaN(after)) { after = Date.parse(retryAfter) - Date.now(); + } else if (after >= Date.parse('2024-01-01')) { + // A large number is treated as a timestamp (fixed threshold protects against clock skew) + after -= Date.now(); } const max = this._options.retry.maxRetryAfter ?? after; diff --git a/source/types/options.ts b/source/types/options.ts index bcdd15b7..1142617a 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -120,7 +120,7 @@ export type KyOptions = { 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 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 `Retry-After` is missing, the non-standard [`RateLimit-Reset`](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#section-3.3) header is used in its place as a fallback. 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. diff --git a/test/retry.ts b/test/retry.ts index 787525fe..f45497e9 100644 --- a/test/retry.ts +++ b/test/retry.ts @@ -124,6 +124,71 @@ test('respect Retry-After: 0 and retry immediately', async t => { await server.close(); }); +test('RateLimit-Reset is treated the same as Retry-After', async t => { + let requestCount = 0; + + const server = await createHttpTestServer(); + server.get('/', (_request, response) => { + requestCount++; + + if (requestCount === defaultRetryCount + 1) { + response.end(fixture); + } else { + const header = (requestCount < 2) ? 'RateLimit-Reset' : 'Retry-After'; + response.writeHead(429, { + [header]: 1, + }); + + response.end(''); + } + }); + + await withPerformance({ + t, + expectedDuration: 1000 + 1000, + async test() { + t.is(await ky(server.url).text(), fixture); + }, + }); + + t.is(requestCount, 3); + + await server.close(); +}); + +test('RateLimit-Reset with time since epoch', async t => { + let requestCount = 0; + + const server = await createHttpTestServer(); + server.get('/', (_request, response) => { + requestCount++; + + if (requestCount === defaultRetryCount + 1) { + response.end(fixture); + } else { + const twoSecondsByDelta = 2; + const oneSecondByEpoch = (Date.now() / 1000) + 1; + response.writeHead(429, { + 'RateLimit-Reset': (requestCount < 2) ? twoSecondsByDelta : oneSecondByEpoch, + }); + + response.end(''); + } + }); + + await withPerformance({ + t, + expectedDuration: 2000 + 1000, + async test() { + t.is(await ky(server.url).text(), fixture); + }, + }); + + t.is(requestCount, 3); + + await server.close(); +}); + test('respect 413 Retry-After', async t => { const startTime = Date.now(); let requestCount = 0;