From e0c8c49fafac235c070550efbd45dc9dcbda4027 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 25 Aug 2023 08:28:59 -1000 Subject: [PATCH] feat: automatically retry rate-limited requests (#319) * feat: automatically retry requests with a `429: Too many requests` API error response * docs: fix TSDoc descriptions * test: refactor variable names and descriptions * test: retry with fallback delay and when the API returns a non-2xx response * test: remove unused test logic --- src/createClient.ts | 38 ++++++++++ test/client.test.ts | 170 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/src/createClient.ts b/src/createClient.ts index 88e75eea..1929410d 100644 --- a/src/createClient.ts +++ b/src/createClient.ts @@ -51,6 +51,15 @@ export const REPOSITORY_CACHE_TTL = 5000; */ export const GET_ALL_QUERY_DELAY = 500; +/** + * The default number of milliseconds to wait before retrying a rate-limited + * `fetch()` request (429 response code). The default value is only used if the + * response does not include a `retry-after` header. + * + * The API allows up to 200 requests per second. + */ +const DEFUALT_RETRY_AFTER_MS = 1000; + /** * Extracts one or more Prismic document types that match a given Prismic * document type. If no matches are found, no extraction is performed and the @@ -120,10 +129,18 @@ export interface RequestInitLike extends Pick { */ export interface ResponseLike { status: number; + headers: HeadersLike; // eslint-disable-next-line @typescript-eslint/no-explicit-any json(): Promise; } +/** + * The minimum required properties from Headers. + */ +export interface HeadersLike { + get(name: string): string | null; +} + /** * The minimum required properties to treat as an HTTP Request for automatic * Prismic preview support. @@ -342,6 +359,7 @@ type ResolvePreviewArgs = { // eslint-disable-next-line @typescript-eslint/no-explicit-any type FetchJobResult = { status: number; + headers: HeadersLike; json: TJSON; }; @@ -1839,6 +1857,7 @@ export class Client { return { status: res.status, + headers: res.headers, json, }; }) @@ -1896,6 +1915,25 @@ export class Client { undefined, ); } + + // Too Many Requests + // - Exceeded the maximum number of requests per second + case 429: { + const parsedRetryAfter = Number(res.headers.get("retry-after")); + const delay = Number.isNaN(parsedRetryAfter) + ? DEFUALT_RETRY_AFTER_MS + : parsedRetryAfter; + + return await new Promise((resolve, reject) => { + setTimeout(async () => { + try { + resolve(await this.fetch(url, params)); + } catch (error) { + reject(error); + } + }, delay); + }); + } } throw new PrismicError(undefined, url, res.json); diff --git a/test/client.test.ts b/test/client.test.ts index b54bd1b0..c4bcbe9a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -683,3 +683,173 @@ it("throws NotFoundError if repository does not exist", async (ctx) => { ); await expect(() => client.get()).rejects.toThrowError(prismic.NotFoundError); }); + +it("retries after `retry-after` milliseconds if response code is 429", async (ctx) => { + const retryAfter = 200; // ms + /** + * The number of milliseconds that time-measuring tests can vary. + */ + const testTolerance = 100; + /** + * The number of times 429 is returned. + */ + const retryResponseQty = 2; + + const queryResponse = prismicM.api.query({ seed: ctx.task.name }); + + mockPrismicRestAPIV2({ + ctx, + queryResponse, + }); + + const client = createTestClient(); + + const queryEndpoint = new URL( + "documents/search", + `${client.endpoint}/`, + ).toString(); + + let responseTries = 0; + + // Override the query endpoint to return a 429 while `responseTries` is + // less than or equal to `retryResponseQty` + ctx.server.use( + msw.rest.get(queryEndpoint, (_req, res, ctx) => { + responseTries++; + + if (responseTries <= retryResponseQty) { + return res( + ctx.status(429), + ctx.json({ + status_code: 429, + status_message: + "Your request count (11) is over the allowed limit of 10.", + }), + ctx.set("retry-after", retryAfter.toString()), + ); + } + }), + ); + + // Rate limited. Should resolve roughly after retryAfter * retryResponseQty milliseconds. + const t0_0 = performance.now(); + const res0 = await client.get(); + const t0_1 = performance.now(); + + expect(res0).toStrictEqual(queryResponse); + expect(t0_1 - t0_0).toBeGreaterThanOrEqual(retryAfter * retryResponseQty); + expect(t0_1 - t0_0).toBeLessThanOrEqual( + retryAfter * retryResponseQty + testTolerance, + ); + + // Not rate limited. Should resolve nearly immediately. + const t1_0 = performance.now(); + const res1 = await client.get(); + const t1_1 = performance.now(); + + expect(res1).toStrictEqual(queryResponse); + expect(t1_1 - t1_0).toBeGreaterThanOrEqual(0); + expect(t1_1 - t1_0).toBeLessThanOrEqual(testTolerance); +}); + +it("retries after 1000 milliseconds if response code is 429 and an invalid `retry-after` value is returned", async (ctx) => { + /** + * The number of milliseconds that time-measuring tests can vary. + */ + const testTolerance = 100; + + const queryResponse = prismicM.api.query({ seed: ctx.task.name }); + + mockPrismicRestAPIV2({ + ctx, + queryResponse, + }); + + const client = createTestClient(); + + const queryEndpoint = new URL( + "documents/search", + `${client.endpoint}/`, + ).toString(); + + let responseTries = 0; + + // Override the query endpoint to return a 429 while `responseTries` is + // less than or equal to `retryResponseQty` + ctx.server.use( + msw.rest.get(queryEndpoint, (_req, res, ctx) => { + responseTries++; + + if (responseTries <= 1) { + return res( + ctx.status(429), + ctx.json({ + status_code: 429, + status_message: + "Your request count (11) is over the allowed limit of 10.", + }), + ctx.set("retry-after", "invalid"), + ); + } + }), + ); + + // Rate limited. Should resolve roughly after 1000 milliseconds. + const t0 = performance.now(); + const res = await client.get(); + const t1 = performance.now(); + + expect(res).toStrictEqual(queryResponse); + expect(t1 - t0).toBeGreaterThanOrEqual(1000); + expect(t1 - t0).toBeLessThanOrEqual(1000 + testTolerance); +}); + +it("throws if a non-2xx response is returned even after retrying", async (ctx) => { + /** + * The number of milliseconds that time-measuring tests can vary. + */ + const testTolerance = 100; + + mockPrismicRestAPIV2({ ctx }); + + const client = createTestClient(); + + const queryEndpoint = new URL( + "documents/search", + `${client.endpoint}/`, + ).toString(); + + let responseTries = 0; + + // Override the query endpoint to return a 429 while `responseTries` is + // less than or equal to `retryResponseQty` + ctx.server.use( + msw.rest.get(queryEndpoint, (_req, res, ctx) => { + responseTries++; + + if (responseTries <= 1) { + return res( + ctx.status(429), + ctx.json({ + status_code: 429, + status_message: + "Your request count (11) is over the allowed limit of 10.", + }), + ctx.set("retry-after", "invalid"), + ); + } else { + return res(ctx.status(418)); + } + }), + ); + + // Rate limited. Should reject roughly after 1000 milliseconds. + const t0 = performance.now(); + await expect(() => client.get()).rejects.toThrowError( + /invalid api response/i, + ); + const t1 = performance.now(); + + expect(t1 - t0).toBeGreaterThanOrEqual(1000); + expect(t1 - t0).toBeLessThanOrEqual(1000 + testTolerance); +});