Skip to content

Commit

Permalink
feat: automatically retry rate-limited requests (#319)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
angeloashmore authored Aug 25, 2023
1 parent af6a593 commit e0c8c49
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 0 deletions.
38 changes: 38 additions & 0 deletions src/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,10 +129,18 @@ export interface RequestInitLike extends Pick<RequestInit, "cache"> {
*/
export interface ResponseLike {
status: number;
headers: HeadersLike;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json(): Promise<any>;
}

/**
* 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.
Expand Down Expand Up @@ -342,6 +359,7 @@ type ResolvePreviewArgs<LinkResolverReturnType> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FetchJobResult<TJSON = any> = {
status: number;
headers: HeadersLike;
json: TJSON;
};

Expand Down Expand Up @@ -1839,6 +1857,7 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {

return {
status: res.status,
headers: res.headers,
json,
};
})
Expand Down Expand Up @@ -1896,6 +1915,25 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
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);
Expand Down
170 changes: 170 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

0 comments on commit e0c8c49

Please sign in to comment.