From 9180cf770a7fef10ce9f6aeaf4810863a05c1700 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 10:11:15 -0400 Subject: [PATCH 1/3] Add maxReset option --- packages/client/src/api.ts | 12 +++++- packages/client/test/api.test.ts | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 878bcbf5f..9e7f45eca 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -19,6 +19,10 @@ const STABLE_CONNECTION_THRESHOLD_MS = 30_000; // counting. const MAX_UNSTABLE_CONNECTION_COUNT = 15; +// The max number of times a request is retried after a retryable failure, +// unless overridden by `ApiRequestConfig.maxRetries`. +const DEFAULT_MAX_REQUEST_RETRIES = 3; + type ApiClientOptions = { host: string; apiKey: string; @@ -44,6 +48,8 @@ export type ApiRequestConfig = { data?: unknown; headers?: HeadersInit; signal?: AbortSignal; + /** Max number of retries for retryable failures. Set this to 0 to disable retries. */ + maxRetries?: number; }; type FetchClient = ( @@ -249,9 +255,11 @@ class ApiClient { private async requestWithRetries(req: ApiRequestConfig) { let lastError: unknown; + const { maxRetries = DEFAULT_MAX_REQUEST_RETRIES } = req; + // Sequential retry loop: each attempt awaits in order and returns early on // success or a non-retryable error, so it doesn't reduce to an array method. - for (let attempt = 0; attempt <= 3; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { let retryAfterMs = 0; try { @@ -279,7 +287,7 @@ class ApiClient { } } - if (attempt < 3) { + if (attempt < maxRetries) { await this.delay(this.getRetryDelay(attempt + 1, retryAfterMs)); } } diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 757d48287..c01c87453 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -586,6 +586,75 @@ describe("API Client", () => { expect(response.statusCode).toBe("error"); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + test("does not retry when maxRetries is 0", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ error: "Server Error" }, 500), + ) + .mockResolvedValue(createJsonResponse({ success: true })); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + maxRetries: 0, + }); + + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + consoleSpy.mockRestore(); + } + }); + + test("retries up to maxRetries times when overridden", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + // Return a fresh Response per call, like a real fetch — a Response body + // can only be consumed once, so each retry must get its own. + const fetchMock = vi + .fn() + .mockImplementation(() => + createJsonResponse({ error: "Server Error" }, 500), + ); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + maxRetries: 1, + }); + + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + + // The initial attempt plus one retry + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + consoleSpy.mockRestore(); + } + }); }); describe("Request Configuration", () => { From dc6010247a524d5c8d81b4e87acddb83fc60ff19 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 10:22:07 -0400 Subject: [PATCH 2/3] Add changeset --- .changeset/ripe-pandas-eat.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/ripe-pandas-eat.md diff --git a/.changeset/ripe-pandas-eat.md b/.changeset/ripe-pandas-eat.md new file mode 100644 index 000000000..89363ed9f --- /dev/null +++ b/.changeset/ripe-pandas-eat.md @@ -0,0 +1,15 @@ +--- +"@knocklabs/client": minor +--- + +Add `maxRetries` option to `ApiRequestConfig` + +When making a request to the Knock API using `ApiClient`'s `makeRequest` function, the `maxRetries` option of `ApiRequestConfig` can now be used to set the maximum number of times the request should be retried when a retryable error occurs. To disable retries altogether, set `maxRetries` to 0. + +```js +const result = await knock.client().makeRequest({ + method: "GET", + url: `/v1/users`, + maxRetries: 0, +}); +``` From ee69dec027e4eecd1c185b0ef6d18d44ac3553c0 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 11:07:48 -0400 Subject: [PATCH 3/3] Update changeset --- .changeset/ripe-pandas-eat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ripe-pandas-eat.md b/.changeset/ripe-pandas-eat.md index 89363ed9f..c22d566de 100644 --- a/.changeset/ripe-pandas-eat.md +++ b/.changeset/ripe-pandas-eat.md @@ -9,7 +9,7 @@ When making a request to the Knock API using `ApiClient`'s `makeRequest` functio ```js const result = await knock.client().makeRequest({ method: "GET", - url: `/v1/users`, + url: "/v1/users", maxRetries: 0, }); ```