From c5e434bafc07dcd0e3ad8109c013160087726b9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Jun 2026 18:44:58 +0000 Subject: [PATCH 1/9] Replace client axios transport with fetch Co-authored-by: Chris Bell --- .changeset/remove-client-axios.md | 5 + packages/client/package.json | 2 - packages/client/src/api.ts | 251 ++++++++-- packages/client/test/api.test.ts | 556 ++++++++--------------- packages/client/test/setup.ts | 1 - packages/client/test/test-utils/mocks.ts | 52 --- yarn.lock | 86 +--- 7 files changed, 418 insertions(+), 535 deletions(-) create mode 100644 .changeset/remove-client-axios.md diff --git a/.changeset/remove-client-axios.md b/.changeset/remove-client-axios.md new file mode 100644 index 000000000..54ba88274 --- /dev/null +++ b/.changeset/remove-client-axios.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/client": patch +--- + +Replace the internal axios transport with native fetch and remove axios dependencies. diff --git a/packages/client/package.json b/packages/client/package.json index d3311d524..daafb84aa 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -72,8 +72,6 @@ "@knocklabs/types": "workspace:^", "@tanstack/store": "^0.7.2", "@types/phoenix": "^1.6.7", - "axios": "^1.15.1", - "axios-retry": "^4.5.0", "eventemitter2": "^6.4.5", "nanoid": "^3.3.12", "phoenix": "1.8.5", diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index e69fcfc33..2a7eb5ca1 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -1,5 +1,3 @@ -import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; -import axiosRetry from "axios-retry"; import { Socket } from "phoenix"; import { exponentialBackoffFullJitter } from "./helpers"; @@ -23,12 +21,50 @@ export interface ApiResponse { status: number; } +export type ApiRequestConfig = { + method?: string; + url?: string; + params?: Record | URLSearchParams; + data?: unknown; + headers?: HeadersInit; + signal?: AbortSignal; +}; + +type FetchClient = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +type ErrorWithResponse = Error & { + response?: { + status: number; + data?: unknown; + }; +}; + +class ApiRequestError extends Error { + response: { + status: number; + data?: unknown; + }; + + constructor(response: Response, data: unknown) { + super(`Request failed with status code ${response.status}`); + this.name = "ApiRequestError"; + this.response = { + status: response.status, + data, + }; + } +} + class ApiClient { private host: string; private apiKey: string; private userToken: string | null; private branch: string | null; - private axiosClient: AxiosInstance; + private fetchClient: FetchClient; + private defaultHeaders: Record; public socket: Socket | undefined; private pageVisibility: PageVisibilityManager | undefined; @@ -39,17 +75,14 @@ class ApiClient { this.userToken = options.userToken || null; this.branch = options.branch || null; - // Create a retryable axios client - this.axiosClient = axios.create({ - baseURL: this.host, - headers: { - Accept: "application/json", - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - "X-Knock-User-Token": this.userToken, - "X-Knock-Client": this.getKnockClientHeader(), - "X-Knock-Branch": this.branch, - }, + this.fetchClient = fetch.bind(globalThis); + this.defaultHeaders = this.compactHeaders({ + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "X-Knock-User-Token": this.userToken, + "X-Knock-Client": this.getKnockClientHeader(), + "X-Knock-Branch": this.branch, }); if (typeof window !== "undefined") { @@ -77,38 +110,159 @@ class ApiClient { this.pageVisibility = new PageVisibilityManager(this.socket); } } - - axiosRetry(this.axiosClient, { - retries: 3, - retryCondition: this.canRetryRequest, - retryDelay: axiosRetry.exponentialDelay, - }); } - async makeRequest(req: AxiosRequestConfig): Promise { + async makeRequest(req: ApiRequestConfig): Promise { try { - const result = await this.axiosClient(req); + const result = await this.requestWithRetries(req); + const body = await this.parseResponseBody(result); return { - statusCode: result.status < 300 ? "ok" : "error", - body: result.data, - error: undefined, + statusCode: result.ok ? "ok" : "error", + body, + error: result.ok ? undefined : new ApiRequestError(result, body), status: result.status, }; // eslint:disable-next-line } catch (e: unknown) { console.error(e); + const response = (e as ErrorWithResponse)?.response; return { statusCode: "error", - status: 500, - body: undefined, + status: response?.status ?? 500, + body: response?.data, error: e, }; } } + private async requestWithRetries(req: ApiRequestConfig) { + let lastError: unknown; + + for (let attempt = 0; attempt <= 3; attempt++) { + try { + const response = await this.fetchClient( + this.buildUrl(req.url, req.params), + this.buildRequestInit(req), + ); + + if (!response.ok && this.canRetryRequest({ response })) { + lastError = new ApiRequestError( + response, + await this.parseResponseBody(response.clone()), + ); + } else { + return response; + } + } catch (error) { + lastError = error; + + if (!this.canRetryRequest(error)) { + throw error; + } + } + + if (attempt < 3) { + await this.delay(this.getRetryDelay(attempt + 1)); + } + } + + throw lastError; + } + + private buildRequestInit(req: ApiRequestConfig): RequestInit { + return { + method: req.method, + headers: { + ...this.defaultHeaders, + ...this.compactHeaders(req.headers), + }, + body: req.data === undefined ? undefined : JSON.stringify(req.data), + signal: req.signal, + }; + } + + private buildUrl(path = "", params?: ApiRequestConfig["params"]) { + const url = new URL(path, this.host); + + if (params) { + if (params instanceof URLSearchParams) { + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } else { + Object.entries(params).forEach(([key, value]) => { + this.appendSearchParam(url.searchParams, key, value); + }); + } + } + + return url.toString(); + } + + private appendSearchParam( + searchParams: URLSearchParams, + key: string, + value: unknown, + ) { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + this.appendSearchParam(searchParams, `${key}[]`, item); + }); + return; + } + + if (value instanceof Date) { + searchParams.append(key, value.toISOString()); + return; + } + + if (typeof value === "object") { + Object.entries(value).forEach(([nestedKey, nestedValue]) => { + this.appendSearchParam( + searchParams, + `${key}[${nestedKey}]`, + nestedValue, + ); + }); + return; + } + + searchParams.append(key, String(value)); + } + + private async parseResponseBody(response: Response) { + if (response.status === 204) { + return undefined; + } + + const text = await response.text(); + + if (!text) { + return undefined; + } + + try { + return JSON.parse(text); + } catch { + return text; + } + } + + private delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private getRetryDelay(retryCount: number) { + return Math.min(100 * 2 ** retryCount, 30_000); + } + teardown() { this.pageVisibility?.teardown(); @@ -117,30 +271,61 @@ class ApiClient { } } - private canRetryRequest(error: AxiosError) { - // Retry Network Errors. - if (axiosRetry.isNetworkError(error)) { + private canRetryRequest(error: unknown) { + if (error instanceof TypeError) { return true; } - if (!error.response) { + const response = (error as ErrorWithResponse)?.response; + + if (!response) { // Cannot determine if the request can be retried return false; } // Retry Server Errors (5xx). - if (error.response.status >= 500 && error.response.status <= 599) { + if (response.status >= 500 && response.status <= 599) { return true; } // Retry if rate limited. - if (error.response.status === 429) { + if (response.status === 429) { return true; } return false; } + private compactHeaders(headers?: Record | HeadersInit) { + const output: Record = {}; + + if (!headers) { + return output; + } + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + output[key] = value; + }); + return output; + } + + if (Array.isArray(headers)) { + headers.forEach(([key, value]) => { + output[key] = value; + }); + return output; + } + + Object.entries(headers).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + output[key] = String(value); + } + }); + + return output; + } + private getKnockClientHeader() { // Note: we're following format used in our Stainless SDKs: // https://github.com/knocklabs/knock-node/blob/main/src/client.ts#L335 diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 44f9f031b..aa2f266fb 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1,38 +1,38 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { Socket } from "phoenix"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import packageJson from "../package.json"; import ApiClient from "../src/api"; -import { createAxiosMock, mockAxios } from "./test-utils/mocks"; - const TEST_BRANCH_SLUG = "lorem-ipsum-dolor-branch"; -// Type for accessing global properties type GlobalWithWindow = Record; -// Use vi.hoisted to ensure proper mock setup -const { mockIsNetworkError, mockExponentialDelay, mockAxiosRetry } = vi.hoisted( - () => { - const mockIsNetworkError = vi.fn(); - const mockExponentialDelay = vi.fn().mockReturnValue(1000); - const mockAxiosRetry = Object.assign(vi.fn(), { - isNetworkError: mockIsNetworkError, - exponentialDelay: mockExponentialDelay, - }); - - return { mockIsNetworkError, mockExponentialDelay, mockAxiosRetry }; - }, -); +const createJsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); -// Mock axios-retry using the hoisted mocks -vi.mock("axios-retry", () => ({ - default: mockAxiosRetry, - isNetworkError: mockIsNetworkError, - exponentialDelay: mockExponentialDelay, -})); +const getDefaultHeaders = (apiClient: ApiClient) => + (apiClient as unknown as Record).defaultHeaders as Record< + string, + string + >; + +const setFetchMock = ( + apiClient: ApiClient, + fetchMock: ReturnType, +) => { + (apiClient as unknown as Record).fetchClient = fetchMock; +}; + +const skipRetryDelays = (apiClient: ApiClient) => { + (apiClient as unknown as Record).delay = vi + .fn() + .mockResolvedValue(undefined); +}; -// Mock Phoenix Socket directly in this file - vi.mock() calls are hoisted vi.mock("phoenix", () => ({ Socket: vi.fn().mockImplementation(() => ({ connect: vi.fn(), @@ -48,22 +48,8 @@ vi.mock("phoenix", () => ({ })), })); -// Apply module-level mocks -mockAxios(); - -/** - * Modern API Client Test Suite - * - * This test suite demonstrates modern testing practices including: - * - Realistic network simulation - * - Environment-specific testing (browser vs server) - * - Comprehensive error handling scenarios - * - Network resilience testing - * - Performance characteristics testing - */ describe("API Client", () => { beforeEach(() => { - // Clean slate for each test vi.clearAllMocks(); }); @@ -81,7 +67,6 @@ describe("API Client", () => { }); expect(apiClient).toBeInstanceOf(ApiClient); - // Don't test private properties directly - just verify it was created }); test("handles user token in configuration", () => { @@ -91,15 +76,13 @@ describe("API Client", () => { userToken: "user_token_456", }); - expect(apiClient).toBeInstanceOf(ApiClient); - // Don't test private properties directly - just verify it was created + expect(getDefaultHeaders(apiClient)["X-Knock-User-Token"]).toBe( + "user_token_456", + ); }); test("initializes WebSocket in browser environment", () => { - // Store original window value const originalWindow = (global as GlobalWithWindow).window; - - // Mock window to simulate browser environment (global as GlobalWithWindow).window = {} as Window; const apiClient = new ApiClient({ @@ -108,15 +91,12 @@ describe("API Client", () => { userToken: undefined, }); - // With mocked Phoenix Socket, socket should be defined in browser environment expect(apiClient.socket).toBeDefined(); - // Restore original window value (global as GlobalWithWindow).window = originalWindow; }); test("skips WebSocket in server environment", () => { - // Ensure window is undefined (server environment) const originalWindow = (global as GlobalWithWindow).window; (global as GlobalWithWindow).window = undefined; @@ -128,7 +108,6 @@ describe("API Client", () => { expect(apiClient.socket).toBeUndefined(); - // Restore original window value (global as GlobalWithWindow).window = originalWindow; }); @@ -169,19 +148,18 @@ describe("API Client", () => { }); describe("Request Handling", () => { - test("makes successful API requests", async () => { - const mockHttp = createAxiosMock(); + test("makes successful API requests with fetch", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); - - // Mock the internal axios client - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - mockHttp.mockSuccess({ data: "test response" }); + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + data: "test response", + }), + ); + setFetchMock(apiClient, fetchMock); const response = await apiClient.makeRequest({ method: "GET", @@ -190,49 +168,56 @@ describe("API Client", () => { expect(response.statusCode).toBe("ok"); expect(response.body.data).toBe("test response"); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.knock.app/test", + expect.objectContaining({ method: "GET" }), + ); }); - test("handles request with parameters", async () => { - const mockHttp = createAxiosMock(); + test("serializes request parameters with axios-compatible brackets", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse({ ok: true })); + setFetchMock(apiClient, fetchMock); - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - mockHttp.mockSuccess({ received: true }); - - const response = await apiClient.makeRequest({ + await apiClient.makeRequest({ method: "GET", url: "/test", - params: { filter: "active" }, - }); - - expect(response.statusCode).toBe("ok"); - expect(mockHttp.axios).toHaveBeenCalledWith( - expect.objectContaining({ - params: { filter: "active" }, - }), - ); + params: { + filter: "active", + workflow_categories: ["billing", "security"], + nested: { enabled: true }, + ignored: undefined, + }, + }); + + const requestUrl = new URL(fetchMock.mock.calls[0]![0] as string); + expect(requestUrl.searchParams.get("filter")).toBe("active"); + expect(requestUrl.searchParams.getAll("workflow_categories[]")).toEqual([ + "billing", + "security", + ]); + expect(requestUrl.searchParams.get("nested[enabled]")).toBe("true"); + expect(requestUrl.searchParams.has("ignored")).toBe(false); }); - test("handles POST requests with data", async () => { - const mockHttp = createAxiosMock(); + test("sends POST requests with JSON data", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); - - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse({ created: true })); + setFetchMock(apiClient, fetchMock); const testData = { name: "Test", value: 42 }; - mockHttp.mockSuccess({ created: true }); - const response = await apiClient.makeRequest({ method: "POST", url: "/test", @@ -240,122 +225,53 @@ describe("API Client", () => { }); expect(response.statusCode).toBe("ok"); - expect(mockHttp.axios).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( + "https://api.knock.app/test", expect.objectContaining({ - data: testData, + body: JSON.stringify(testData), }), ); }); + + test("parses empty and text responses", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response("accepted", { status: 202 })); + setFetchMock(apiClient, fetchMock); + + await expect( + apiClient.makeRequest({ url: "/empty" }), + ).resolves.toMatchObject({ + body: undefined, + status: 204, + statusCode: "ok", + }); + await expect( + apiClient.makeRequest({ url: "/text" }), + ).resolves.toMatchObject({ + body: "accepted", + status: 202, + statusCode: "ok", + }); + }); }); describe("Error Handling", () => { test("handles network errors gracefully", async () => { - // Suppress console.error for this expected error test - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - try { - const mockHttp = createAxiosMock(); - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, - }); - - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - // Mock network failure - this should not create unhandled rejections - const networkError = new Error("Network Error"); - ( - networkError as unknown as { code?: string; isAxiosError?: boolean } - ).code = "ECONNABORTED"; - (networkError as unknown as { isAxiosError?: boolean }).isAxiosError = - true; - mockHttp.axios.mockRejectedValue(networkError); - - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - }); - - expect(response.statusCode).toBe("error"); - expect(response.error).toBe(networkError); - } finally { - consoleSpy.mockRestore(); - } - }); - - test("handles different error types appropriately", async () => { - // Suppress console.error for this expected error test - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - try { - const mockHttp = createAxiosMock(); - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, - }); - - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - const errorScenarios = [ - { - name: "timeout", - error: { - message: "timeout of 5000ms exceeded", - code: "ECONNABORTED", - }, - }, - { - name: "server error", - error: { - message: "Server Error", - response: { status: 500, data: { error: "Internal Error" } }, - }, - }, - { - name: "not found", - error: { - message: "Not Found", - response: { status: 404, data: { error: "Resource not found" } }, - }, - }, - ]; - - for (const scenario of errorScenarios) { - mockHttp.axios.mockRejectedValueOnce(scenario.error); - - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - }); - - expect(response.statusCode).toBe("error"); - expect(response.error).toBe(scenario.error); - } - } finally { - consoleSpy.mockRestore(); - } - }); - - test("handles API error responses", async () => { - const mockHttp = createAxiosMock(); + const networkError = new TypeError("Failed to fetch"); const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); - - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - mockHttp.mockError(500, "Internal Server Error"); + setFetchMock(apiClient, vi.fn().mockRejectedValue(networkError)); + skipRetryDelays(apiClient); const response = await apiClient.makeRequest({ method: "GET", @@ -364,176 +280,127 @@ describe("API Client", () => { expect(response.statusCode).toBe("error"); expect(response.status).toBe(500); - }); - }); - - describe("Retry and Resilience", () => { - test("implements error handling for transient failures", async () => { - // Suppress console.error for this expected error test - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - try { - const mockHttp = createAxiosMock(); - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, - }); - - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - // Mock network timeout - should not create unhandled rejections - mockHttp.axios.mockRejectedValueOnce(new Error("Network timeout")); - - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - }); - - // Should handle the error gracefully - expect(response.statusCode).toBe("error"); - expect(response.error).toBeInstanceOf(Error); - expect(response.error.message).toBe("Network timeout"); - } finally { - consoleSpy.mockRestore(); - } + expect(response.error).toBe(networkError); }); - test("retries on network errors", async () => { - // Configure the mock to return true for network errors - mockIsNetworkError.mockReturnValue(true); - + test("handles API error responses with response metadata", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + setFetchMock( + apiClient, + vi + .fn() + .mockResolvedValue(createJsonResponse({ error: "Not found" }, 404)), + ); - // Mock network error - const networkError = new Error("Network Error"); - ( - networkError as unknown as { code?: string; isAxiosError?: boolean } - ).code = "ECONNABORTED"; - (networkError as unknown as { isAxiosError?: boolean }).isAxiosError = - true; - - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(networkError)).toBe(true); - expect(mockIsNetworkError).toHaveBeenCalledWith(networkError); - }); - - test("retries on 5xx server errors", async () => { - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", }); - const serverErrors = [500, 501, 502, 503, 504, 599]; - - serverErrors.forEach((status) => { - const serverError = { - response: { status }, - isAxiosError: true, - }; - - // Mock axiosRetry.isNetworkError to return false for server errors - mockIsNetworkError.mockReturnValue(false); - - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(serverError)).toBe(true); - }); + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: "Not found" }); + expect(response.error.response.status).toBe(404); + expect(response.error.response.data).toEqual({ error: "Not found" }); }); + }); - test("retries on rate limit errors (429)", async () => { + describe("Retry and Resilience", () => { + test("retries on network errors", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue(createJsonResponse({ success: true })); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); - const rateLimitError = { - response: { status: 429 }, - isAxiosError: true, - }; - - mockIsNetworkError.mockReturnValue(false); + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(rateLimitError)).toBe(true); + expect(response.statusCode).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - test("does not retry on client errors (4xx except 429)", async () => { + test("retries on 5xx server errors", 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 clientErrors = [400, 401, 403, 404, 422]; - - clientErrors.forEach((status) => { - const clientError = { - response: { status }, - isAxiosError: true, - }; - - // Mock axiosRetry.isNetworkError to return false - mockIsNetworkError.mockReturnValue(false); - - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(clientError)).toBe(false); + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", }); + + expect(response.statusCode).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - test("does not retry when response is undefined", async () => { + test("retries on rate limit errors (429)", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ error: "Rate limited" }, 429), + ) + .mockResolvedValue(createJsonResponse({ success: true })); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); - const errorWithoutResponse = { - isAxiosError: true, - response: undefined, - }; - - mockIsNetworkError.mockReturnValue(false); + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(errorWithoutResponse)).toBe(false); + expect(response.statusCode).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - test("does not retry on successful 2xx responses", async () => { + test("does not retry on client errors (4xx except 429)", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + const fetchMock = vi + .fn() + .mockResolvedValue( + createJsonResponse({ error: "Resource not found" }, 404), + ); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); - const successResponses = [200, 201, 204]; - - successResponses.forEach((status) => { - const successError = { - response: { status }, - isAxiosError: true, - }; - - // Mock axiosRetry.isNetworkError to return false - mockIsNetworkError.mockReturnValue(false); - - const canRetry = (apiClient as unknown as Record) - .canRetryRequest as (error: unknown) => boolean; - expect(canRetry(successError)).toBe(false); + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", }); + + expect(response.statusCode).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -545,11 +412,7 @@ describe("API Client", () => { userToken: undefined, }); - // Access the private axios client to check headers - const axiosClient = (apiClient as unknown as Record) - .axiosClient as { defaults: { headers: Record } }; - - expect(axiosClient.defaults.headers["X-Knock-Client"]).toBe( + expect(getDefaultHeaders(apiClient)["X-Knock-Client"]).toBe( `Knock/ClientJS ${packageJson.version}`, ); }); @@ -562,75 +425,52 @@ describe("API Client", () => { branch: TEST_BRANCH_SLUG, }); - const axiosClient = (apiClient as unknown as Record) - .axiosClient as { defaults: { headers: Record } }; - - expect(axiosClient.defaults.headers["X-Knock-Branch"]).toBe( + expect(getDefaultHeaders(apiClient)["X-Knock-Branch"]).toBe( TEST_BRANCH_SLUG, ); }); - test("supports various HTTP methods", async () => { - const mockHttp = createAxiosMock(); + test("omits optional headers when values are not configured", () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; - - const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; - - for (const method of methods) { - mockHttp.mockSuccess({ method }); - - const response = await apiClient.makeRequest({ - method: method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH", - url: "/test", - }); - - expect(response.statusCode).toBe("ok"); - expect(response.body.method).toBe(method); - } + expect( + getDefaultHeaders(apiClient)["X-Knock-User-Token"], + ).toBeUndefined(); + expect(getDefaultHeaders(apiClient)["X-Knock-Branch"]).toBeUndefined(); }); - test("handles request parameters correctly", async () => { - const mockHttp = createAxiosMock(); + test("supports various HTTP methods", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", userToken: undefined, }); + const fetchMock = vi.fn((_, init?: RequestInit) => + Promise.resolve(createJsonResponse({ method: init?.method })), + ); + setFetchMock(apiClient, fetchMock); - (apiClient as unknown as Record).axiosClient = - mockHttp.axios; + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; - mockHttp.axios.mockImplementation((config: unknown) => { - return Promise.resolve({ - status: 200, - data: { receivedConfig: config }, + for (const method of methods) { + const response = await apiClient.makeRequest({ + method, + url: "/test", }); - }); - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - params: { filter: "active", limit: 10 }, - }); - - expect(response.statusCode).toBe("ok"); - expect(response.body.receivedConfig).toBeDefined(); + expect(response.statusCode).toBe("ok"); + expect(response.body.method).toBe(method); + } }); }); describe("Socket Connection Management", () => { test("provides socket interface in browser environment", () => { - // Store original window value const originalWindow = (global as GlobalWithWindow).window; - - // Mock window to simulate browser environment (global as GlobalWithWindow).window = {}; const apiClient = new ApiClient({ @@ -641,7 +481,6 @@ describe("API Client", () => { expect(apiClient.socket).toBeDefined(); - // Restore original window value (global as GlobalWithWindow).window = originalWindow; }); @@ -687,14 +526,12 @@ describe("API Client", () => { tries: number, ) => number; - // Call it many times to verify the range holds for (let i = 0; i < 50; i++) { const delay = reconnectAfterMs(1); expect(delay).toBeGreaterThanOrEqual(250); expect(delay).toBeLessThanOrEqual(1000); } - // At high tries, should be capped at 30_000 for (let i = 0; i < 50; i++) { const delay = reconnectAfterMs(100); expect(delay).toBeGreaterThanOrEqual(250); @@ -724,14 +561,12 @@ describe("API Client", () => { tries: number, ) => number; - // Call it many times to verify the range holds for (let i = 0; i < 50; i++) { const delay = rejoinAfterMs(1); expect(delay).toBeGreaterThanOrEqual(250); expect(delay).toBeLessThanOrEqual(1000); } - // At high tries, should be capped at 60_000 for (let i = 0; i < 50; i++) { const delay = rejoinAfterMs(100); expect(delay).toBeGreaterThanOrEqual(250); @@ -742,7 +577,6 @@ describe("API Client", () => { }); test("gracefully handles missing WebSocket in server environment", () => { - // Store original window value const originalWindow = (global as GlobalWithWindow).window; (global as GlobalWithWindow).window = undefined; @@ -752,10 +586,8 @@ describe("API Client", () => { userToken: undefined, }); - // In server environment, socket should be undefined expect(apiClient.socket).toBeUndefined(); - // Restore original window value (global as GlobalWithWindow).window = originalWindow; }); }); diff --git a/packages/client/test/setup.ts b/packages/client/test/setup.ts index 2b68ae122..8b4d475c9 100644 --- a/packages/client/test/setup.ts +++ b/packages/client/test/setup.ts @@ -87,7 +87,6 @@ console.warn = (message: unknown, ...args: unknown[]) => { }; // Completely suppress console.error during tests since we're testing error scenarios -// This prevents axios errors from showing up in test output console.error = noOp; // Suppress console.log during tests to prevent Knock debug messages diff --git a/packages/client/test/test-utils/mocks.ts b/packages/client/test/test-utils/mocks.ts index ebc0c865a..9153d9951 100644 --- a/packages/client/test/test-utils/mocks.ts +++ b/packages/client/test/test-utils/mocks.ts @@ -211,41 +211,6 @@ export const setupBrowserEnvironment = () => { }; }; -// Simple axios mock for API testing -export const createAxiosMock = () => { - const mockAxios = vi.fn(); - - // Default successful response - mockAxios.mockResolvedValue({ - status: 200, - data: { success: true }, - }); - - // Helper methods for common scenarios - const mockSuccess = (data: unknown, status = 200) => { - mockAxios.mockResolvedValue({ status, data }); - }; - - const mockError = (status: number, message = "Error") => { - mockAxios.mockResolvedValue({ - status, - data: { error: message }, - }); - }; - - const mockFailure = (error: Error) => { - mockAxios.mockRejectedValue(error); - }; - - return { - axios: mockAxios, - mockSuccess, - mockError, - mockFailure, - reset: () => mockAxios.mockClear(), - }; -}; - // Simple WebSocket mock export const createWebSocketMock = () => { const mockWebSocket = { @@ -311,23 +276,6 @@ export const createEventEmitterMock = () => ({ removeAllListeners: vi.fn(), }); -// Module-level mocks for common dependencies -export const mockAxios = () => { - vi.mock("axios", () => ({ - default: { - create: vi.fn(() => createAxiosMock().axios), - }, - })); -}; - -export const mockAxiosRetry = () => { - vi.mock("axios-retry", () => ({ - default: vi.fn(), - isNetworkError: vi.fn().mockReturnValue(false), - exponentialDelay: vi.fn().mockReturnValue(1000), - })); -}; - export const mockJwtDecode = () => { vi.mock("jwt-decode", () => ({ jwtDecode: vi.fn().mockReturnValue({ diff --git a/yarn.lock b/yarn.lock index ce0e1d303..0b1b72558 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4863,8 +4863,6 @@ __metadata: "@types/phoenix": "npm:^1.6.7" "@typescript-eslint/eslint-plugin": "npm:^8.59.4" "@typescript-eslint/parser": "npm:^8.59.4" - axios: "npm:^1.15.1" - axios-retry: "npm:^4.5.0" cross-env: "npm:^7.0.3" crypto: "npm:^1.0.1" eslint: "npm:^8.56.0" @@ -10160,13 +10158,6 @@ __metadata: languageName: node linkType: hard -"asynckit@npm:^0.4.0": - version: 0.4.0 - resolution: "asynckit@npm:0.4.0" - checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d - languageName: node - linkType: hard - "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -10183,28 +10174,6 @@ __metadata: languageName: node linkType: hard -"axios-retry@npm:^4.5.0": - version: 4.5.0 - resolution: "axios-retry@npm:4.5.0" - dependencies: - is-retry-allowed: "npm:^2.2.0" - peerDependencies: - axios: 0.x || 1.x - checksum: 10c0/574e7b1bf24aad99b560042d232a932d51bfaa29b5a6d4612d748ed799a6f11a5afb2582792492c55d95842200cbdfbe3454027a8c1b9a2d3e895d13c3d03c10 - languageName: node - linkType: hard - -"axios@npm:^1.15.1": - version: 1.15.2 - resolution: "axios@npm:1.15.2" - dependencies: - follow-redirects: "npm:^1.15.11" - form-data: "npm:^4.0.5" - proxy-from-env: "npm:^2.1.0" - checksum: 10c0/4eeae0feeaa7fdc1ef24f81f8b378fdadedf4aebdd6bf224484675160f8744cf17b9b0d1c215279979940f7e8ce463beffa2f713099612e428eac238515c81d5 - languageName: node - linkType: hard - "axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" @@ -11208,15 +11177,6 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": - version: 1.0.8 - resolution: "combined-stream@npm:1.0.8" - dependencies: - delayed-stream: "npm:~1.0.0" - checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 - languageName: node - linkType: hard - "commander@npm:^12.0.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -11705,13 +11665,6 @@ __metadata: languageName: node linkType: hard -"delayed-stream@npm:~1.0.0": - version: 1.0.0 - resolution: "delayed-stream@npm:1.0.0" - checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 - languageName: node - linkType: hard - "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -14012,16 +13965,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 - languageName: node - linkType: hard - "fontfaceobserver@npm:^2.1.0": version: 2.3.0 resolution: "fontfaceobserver@npm:2.3.0" @@ -14048,19 +13991,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b - languageName: node - linkType: hard - "framer-motion@npm:^12.34.3": version: 12.34.3 resolution: "framer-motion@npm:12.34.3" @@ -15146,13 +15076,6 @@ __metadata: languageName: node linkType: hard -"is-retry-allowed@npm:^2.2.0": - version: 2.2.0 - resolution: "is-retry-allowed@npm:2.2.0" - checksum: 10c0/013be4f8a0a06a49ed1fe495242952e898325d496202a018f6f9fb3fb9ac8fe3b957a9bd62463d68299ae35dbbda680473c85a9bcefca731b49d500d3ccc08ff - languageName: node - linkType: hard - "is-set@npm:^2.0.3": version: 2.0.3 resolution: "is-set@npm:2.0.3" @@ -16815,7 +16738,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.27, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -18320,13 +18243,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^2.1.0": - version: 2.1.0 - resolution: "proxy-from-env@npm:2.1.0" - checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 - languageName: node - linkType: hard - "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" From 6623b815219bb553b1ce505243ba2bb1fc62f87a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Jun 2026 18:47:26 +0000 Subject: [PATCH 2/9] Fix fetch transport type compatibility Co-authored-by: Chris Bell --- packages/client/src/api.ts | 4 ++-- packages/client/test/api.test.ts | 37 +++++++++++++++++++------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 2a7eb5ca1..0e920ebdd 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -24,7 +24,7 @@ export interface ApiResponse { export type ApiRequestConfig = { method?: string; url?: string; - params?: Record | URLSearchParams; + params?: unknown; data?: unknown; headers?: HeadersInit; signal?: AbortSignal; @@ -192,7 +192,7 @@ class ApiClient { params.forEach((value, key) => { url.searchParams.append(key, value); }); - } else { + } else if (typeof params === "object") { Object.entries(params).forEach(([key, value]) => { this.appendSearchParam(url.searchParams, key, value); }); diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index aa2f266fb..3dd7dcc98 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -264,23 +264,30 @@ describe("API Client", () => { describe("Error Handling", () => { test("handles network errors gracefully", async () => { - const networkError = new TypeError("Failed to fetch"); - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, - }); - setFetchMock(apiClient, vi.fn().mockRejectedValue(networkError)); - skipRetryDelays(apiClient); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + const networkError = new TypeError("Failed to fetch"); + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + setFetchMock(apiClient, vi.fn().mockRejectedValue(networkError)); + skipRetryDelays(apiClient); - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - }); + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); - expect(response.statusCode).toBe("error"); - expect(response.status).toBe(500); - expect(response.error).toBe(networkError); + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + expect(response.error).toBe(networkError); + } finally { + consoleSpy.mockRestore(); + } }); test("handles API error responses with response metadata", async () => { From 5dafb6dc8a3f696e66b3f897532b73a64f550c2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Jun 2026 18:54:03 +0000 Subject: [PATCH 3/9] Harden fetch client edge cases Co-authored-by: Chris Bell --- packages/client/src/api.ts | 33 ++++++++++- packages/client/test/api.test.ts | 98 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 0e920ebdd..2ee525520 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -75,7 +75,7 @@ class ApiClient { this.userToken = options.userToken || null; this.branch = options.branch || null; - this.fetchClient = fetch.bind(globalThis); + this.fetchClient = this.getFetchClient(); this.defaultHeaders = this.compactHeaders({ Accept: "application/json", "Content-Type": "application/json", @@ -263,6 +263,19 @@ class ApiClient { return Math.min(100 * 2 ** retryCount, 30_000); } + private getFetchClient(): FetchClient { + if (typeof globalThis.fetch === "function") { + return globalThis.fetch.bind(globalThis); + } + + return () => + Promise.reject( + new Error( + "Fetch is not available in this environment. Please provide a native fetch implementation.", + ), + ); + } + teardown() { this.pageVisibility?.teardown(); @@ -272,7 +285,7 @@ class ApiClient { } private canRetryRequest(error: unknown) { - if (error instanceof TypeError) { + if (this.isFetchNetworkError(error)) { return true; } @@ -296,6 +309,22 @@ class ApiClient { return false; } + private isFetchNetworkError(error: unknown) { + if (error instanceof TypeError) { + return true; + } + + if ( + typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "NetworkError" + ) { + return true; + } + + return false; + } + private compactHeaders(headers?: Record | HeadersInit) { const output: Record = {}; diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 3dd7dcc98..321a43da4 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -263,6 +263,42 @@ describe("API Client", () => { }); describe("Error Handling", () => { + test("returns a helpful error when fetch is unavailable", async () => { + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: undefined, + }); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); + + expect(response.statusCode).toBe("error"); + expect(response.error).toBeInstanceOf(Error); + expect(response.error.message).toBe( + "Fetch is not available in this environment. Please provide a native fetch implementation.", + ); + } finally { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + }); + consoleSpy.mockRestore(); + } + }); + test("handles network errors gracefully", async () => { const consoleSpy = vi .spyOn(console, "error") @@ -339,6 +375,37 @@ describe("API Client", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + test("does not retry aborted requests", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const abortError = new DOMException( + "The operation was aborted.", + "AbortError", + ); + const fetchMock = vi.fn().mockRejectedValue(abortError); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); + + expect(response.statusCode).toBe("error"); + expect(response.error).toBe(abortError); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + consoleSpy.mockRestore(); + } + }); + test("retries on 5xx server errors", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", @@ -363,6 +430,37 @@ describe("API Client", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + test("returns the final retryable response after retries are exhausted", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const fetchMock = vi + .fn() + .mockResolvedValue(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", + }); + + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Server Error" }); + expect(response.error.response.status).toBe(500); + expect(fetchMock).toHaveBeenCalledTimes(4); + } finally { + consoleSpy.mockRestore(); + } + }); + test("retries on rate limit errors (429)", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", From 162283c66007f296e23a7c5cbb0226ab7cffe763 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 09:43:29 -0500 Subject: [PATCH 4/9] test(client): add characterization tests for fetch transport --- .../client/test/api.characterization.test.ts | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 packages/client/test/api.characterization.test.ts diff --git a/packages/client/test/api.characterization.test.ts b/packages/client/test/api.characterization.test.ts new file mode 100644 index 000000000..6cd44808c --- /dev/null +++ b/packages/client/test/api.characterization.test.ts @@ -0,0 +1,340 @@ +/** + * Characterization tests for the ApiClient HTTP transport. + * + * These pin the externally-observable behavior of `makeRequest` — the exact request + * put on the wire and the `ApiResponse` returned — so the axios -> fetch migration + * (PR #1010), and any future transport change, cannot silently regress it. + * + * The expected values below were captured from the previous axios + axios-retry + * implementation and verified to be byte-for-byte equivalent (param serialization, + * headers, body, retry counts, error mapping). This test imports ONLY the client + * under test — no axios — so it stays valid after axios is removed. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import packageJson from "../package.json"; +import ApiClient from "../src/api"; + +vi.mock("phoenix", () => ({ + Socket: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(false), + onOpen: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + channel: vi.fn(), + push: vi.fn(), + teardown: vi.fn(), + })), +})); + +const HOST = "https://api.knock.app"; +const API_KEY = "pk_test_12345"; + +type Outcome = + | { kind: "response"; status: number; body: unknown } + | { kind: "network" }; + +type CapturedRequest = { + method: string; + path: string; + query: [string, string][]; + headers: Record; + body: unknown; +}; + +// Headers the client always sends for an unauthenticated request, no branch. +const BASE_HEADERS: Record = { + accept: "application/json", + "content-type": "application/json", + authorization: `Bearer ${API_KEY}`, + "x-knock-client": `Knock/ClientJS ${packageJson.version}`, +}; + +const normalizeHeaders = ( + h: HeadersInit | undefined, +): Record => { + if (!h) return {}; + const plain = + typeof Headers !== "undefined" && h instanceof Headers + ? Object.fromEntries(h.entries()) + : Array.isArray(h) + ? Object.fromEntries(h) + : (h as Record); + return Object.fromEntries( + Object.entries(plain) + .filter(([, v]) => v !== null && v !== undefined) + .map(([k, v]) => [k.toLowerCase(), String(v)]), + ); +}; + +// Compare the params the server actually receives, independent of `[]` vs `%5B%5D` +// percent-encoding differences. +const splitUrl = ( + u: string, +): { path: string; query: [string, string][] } => { + const url = new URL(u); + return { path: url.origin + url.pathname, query: [...url.searchParams] }; +}; + +const makeClient = ( + opts: { userToken?: string; branch?: string }, + outcomes: Outcome[], +) => { + const client = new ApiClient({ + host: HOST, + apiKey: API_KEY, + userToken: opts.userToken, + branch: opts.branch, + }); + + const captured: CapturedRequest[] = []; + let i = 0; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const { path, query } = splitUrl(String(url)); + captured.push({ + method: String(init?.method ?? "GET").toUpperCase(), + path, + query, + headers: normalizeHeaders(init?.headers), + body: init?.body ?? undefined, + }); + const outcome = outcomes[Math.min(i, outcomes.length - 1)]!; + i += 1; + if (outcome.kind === "network") throw new TypeError("Failed to fetch"); + const hasBody = outcome.body !== undefined && outcome.status !== 204; + return new Response(hasBody ? JSON.stringify(outcome.body) : null, { + status: outcome.status, + headers: hasBody ? { "Content-Type": "application/json" } : undefined, + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).fetchClient = fetchMock; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).delay = vi.fn().mockResolvedValue(undefined); // instant backoff + + return { client, captured }; +}; + +const OK: Outcome[] = [{ kind: "response", status: 200, body: { ok: true } }]; + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ApiClient wire format (characterization)", () => { + test("GET with no params, unauthenticated", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured).toHaveLength(1); + expect(captured[0]!.method).toBe("GET"); + expect(captured[0]!.path).toBe("https://api.knock.app/v1/ping"); + expect(captured[0]!.query).toEqual([]); + expect(captured[0]!.headers).toEqual(BASE_HEADERS); + expect(captured[0]!.body).toBeUndefined(); + }); + + test("array, boolean, and undefined params serialize as before", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ + method: "GET", + url: "/v1/users/u_1/feeds/f_1", + params: { + status: "unread", + tenant: "tenant_1", + has_tenant: true, + page_size: 50, + archived: "exclude", + workflow_categories: ["billing", "security"], + source: undefined, + }, + }); + + expect(captured[0]!.path).toBe("https://api.knock.app/v1/users/u_1/feeds/f_1"); + expect(captured[0]!.query).toEqual([ + ["status", "unread"], + ["tenant", "tenant_1"], + ["has_tenant", "true"], + ["page_size", "50"], + ["archived", "exclude"], + ["workflow_categories[]", "billing"], + ["workflow_categories[]", "security"], + ]); + }); + + test("nested object params serialize with bracket notation (Slack auth_check)", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ + method: "GET", + url: "/v1/providers/slack/ch_1/auth_check", + params: { + access_token_object: { object_id: "tenant_1", collection: "$tenants" }, + channel_id: "ch_1", + }, + }); + + expect(captured[0]!.query).toEqual([ + ["access_token_object[object_id]", "tenant_1"], + ["access_token_object[collection]", "$tenants"], + ["channel_id", "ch_1"], + ]); + }); + + test("nested object + secondary object params (MS Teams channels)", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ + method: "GET", + url: "/v1/providers/ms-teams/ch_1/channels", + params: { + ms_teams_tenant_object: { object_id: "tenant_1", collection: "$tenants" }, + team_id: "team_1", + query_options: { paginationToken: "tok_abc", maxResults: 10 }, + }, + }); + + expect(captured[0]!.query).toEqual([ + ["ms_teams_tenant_object[object_id]", "tenant_1"], + ["ms_teams_tenant_object[collection]", "$tenants"], + ["team_id", "team_1"], + ["query_options[paginationToken]", "tok_abc"], + ["query_options[maxResults]", "10"], + ]); + }); + + test("Date params serialize to ISO 8601", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ + method: "GET", + url: "/v1/messages", + params: { since: new Date("2024-01-02T03:04:05.000Z"), page_size: 25 }, + }); + + expect(captured[0]!.query).toEqual([ + ["since", "2024-01-02T03:04:05.000Z"], + ["page_size", "25"], + ]); + }); + + test("POST serializes JSON body and keeps method", async () => { + const { client, captured } = makeClient({}, [ + { kind: "response", status: 201, body: { created: true } }, + ]); + await client.makeRequest({ + method: "POST", + url: "/v1/users/u_1", + data: { name: "Test", value: 42, nested: { a: 1, b: [1, 2] } }, + }); + + expect(captured[0]!.method).toBe("POST"); + expect(captured[0]!.body).toBe( + '{"name":"Test","value":42,"nested":{"a":1,"b":[1,2]}}', + ); + }); + + test("authenticated request adds user-token and branch headers", async () => { + const { client, captured } = makeClient( + { userToken: "user_token_456", branch: "my-branch" }, + OK, + ); + await client.makeRequest({ method: "GET", url: "/v1/users/u_1" }); + + expect(captured[0]!.headers).toEqual({ + ...BASE_HEADERS, + "x-knock-user-token": "user_token_456", + "x-knock-branch": "my-branch", + }); + }); + + test("user-token and branch headers are omitted when unset", async () => { + const { client, captured } = makeClient({}, OK); + await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured[0]!.headers).not.toHaveProperty("x-knock-user-token"); + expect(captured[0]!.headers).not.toHaveProperty("x-knock-branch"); + }); +}); + +describe("ApiClient response mapping (characterization)", () => { + test("2xx success", async () => { + const { client } = makeClient({}, [ + { kind: "response", status: 200, body: { data: "hello" } }, + ]); + const res = await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(res).toMatchObject({ + statusCode: "ok", + status: 200, + body: { data: "hello" }, + error: undefined, + }); + }); + + test("204 No Content yields an undefined body", async () => { + const { client } = makeClient({}, [ + { kind: "response", status: 204, body: undefined }, + ]); + const res = await client.makeRequest({ method: "DELETE", url: "/v1/thing" }); + + expect(res.statusCode).toBe("ok"); + expect(res.status).toBe(204); + expect(res.body).toBeUndefined(); + }); + + test("network error: retried to 4 total attempts, mapped to status 500", async () => { + const { client, captured } = makeClient({}, [{ kind: "network" }]); + const res = await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured).toHaveLength(4); // 1 initial + 3 retries (matches axios-retry) + expect(res.statusCode).toBe("error"); + expect(res.status).toBe(500); + expect(res.body).toBeUndefined(); + expect(res.error).toBeInstanceOf(Error); + }); + + test("5xx: retried to 4 total attempts, error.response.status preserved", async () => { + const { client, captured } = makeClient({}, [ + { kind: "response", status: 500, body: { error: "boom" } }, + ]); + const res = await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured).toHaveLength(4); + expect(res.statusCode).toBe("error"); + expect(res.error?.response?.status).toBe(500); + }); + + test("429 then 200: retried once and succeeds", async () => { + const { client, captured } = makeClient({}, [ + { kind: "response", status: 429, body: { error: "rate limited" } }, + { kind: "response", status: 200, body: { ok: true } }, + ]); + const res = await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured).toHaveLength(2); + expect(res.statusCode).toBe("ok"); + expect(res.body).toEqual({ ok: true }); + }); + + test("4xx (404): not retried; error.response carries status and body", async () => { + const { client, captured } = makeClient({}, [ + { kind: "response", status: 404, body: { error: "Not found" } }, + ]); + const res = await client.makeRequest({ method: "GET", url: "/v1/ping" }); + + expect(captured).toHaveLength(1); // no retry on 4xx + expect(res.statusCode).toBe("error"); + expect(res.error?.response?.status).toBe(404); + expect(res.error?.response?.data).toEqual({ error: "Not found" }); + + // Behavior change vs the old axios transport (which hardcoded these on error): + // the top-level status/body now reflect the real response. handleResponse + // consumers read error.response.status, so they are unaffected. + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: "Not found" }); + }); +}); From aee13fd61cb4876a5ab5a99042b844162f9eccd2 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 10:01:59 -0500 Subject: [PATCH 5/9] refactor(client): apply TS conventions to api.ts (ApiResponse type alias, drop dead eslint directive, document retry loop) --- packages/client/src/api.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 2ee525520..7abce26dc 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -12,14 +12,14 @@ type ApiClientOptions = { disconnectOnPageHidden?: boolean; }; -export interface ApiResponse { +export type ApiResponse = { // eslint-disable-next-line error?: any; // eslint-disable-next-line body?: any; statusCode: "ok" | "error"; status: number; -} +}; export type ApiRequestConfig = { method?: string; @@ -123,8 +123,6 @@ class ApiClient { error: result.ok ? undefined : new ApiRequestError(result, body), status: result.status, }; - - // eslint:disable-next-line } catch (e: unknown) { console.error(e); const response = (e as ErrorWithResponse)?.response; @@ -141,6 +139,8 @@ class ApiClient { private async requestWithRetries(req: ApiRequestConfig) { let lastError: unknown; + // 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++) { try { const response = await this.fetchClient( From 915dfbbd2bc92238bbfaabe0fdf5534eabc7a953 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 10:14:55 -0500 Subject: [PATCH 6/9] fix(client): drain retry response body directly instead of cloning Addresses Cursor BugBot "Unread bodies on HTTP retries". On retryable 5xx/429 responses the retry loop read a clone and left the original Response body undrained. The response is discarded before the next attempt, so read it directly (no clone), which drains the body and releases the connection. Also updates the retry-exhaustion test to return a fresh Response per call, matching real fetch semantics where a body can only be consumed once. --- packages/client/src/api.ts | 4 +++- packages/client/test/api.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 7abce26dc..9b265af3b 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -149,9 +149,11 @@ class ApiClient { ); if (!response.ok && this.canRetryRequest({ response })) { + // This response is discarded before the next attempt, so read its + // body directly (no clone) to drain it and release the connection. lastError = new ApiRequestError( response, - await this.parseResponseBody(response.clone()), + await this.parseResponseBody(response), ); } else { return response; diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 321a43da4..24dbe05bb 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -436,9 +436,13 @@ describe("API Client", () => { 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() - .mockResolvedValue(createJsonResponse({ error: "Server Error" }, 500)); + .mockImplementation(() => + createJsonResponse({ error: "Server Error" }, 500), + ); setFetchMock(apiClient, fetchMock); skipRetryDelays(apiClient); From 3ef433d5a8a214e1569da03ee406f6d678c96789 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 10:46:29 -0500 Subject: [PATCH 7/9] fix(react-core): detect unconnected Slack/MS Teams via response status, not axios code The connection-status hooks classified a not-yet-connected tenant by reading the axios error code ("ERR_BAD_REQUEST"). The new fetch transport returns an ApiRequestError with no code field, so that branch died and unconnected tenants rendered as a connection "error" instead of "disconnected". Key off response.status (4xx) instead, which both the old and new transports provide. Adds regression tests for the not-set path (previously uncovered) and updates AuthCheckResult to drop the axios-only code field and expose response.status. --- packages/react-core/src/modules/core/types.ts | 2 +- .../hooks/useMsTeamsConnectionStatus.ts | 5 ++++- .../slack/hooks/useSlackConnectionStatus.ts | 5 ++++- .../useMsTeamsConnectionStatus.test.tsx | 19 +++++++++++++++++++ .../slack/useSlackConnectionStatus.test.tsx | 19 +++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/react-core/src/modules/core/types.ts b/packages/react-core/src/modules/core/types.ts index 126ff84b3..4cee068da 100644 --- a/packages/react-core/src/modules/core/types.ts +++ b/packages/react-core/src/modules/core/types.ts @@ -21,8 +21,8 @@ export interface AuthCheckResult { ok?: boolean; error?: string; }; - code?: string; response?: { + status?: number; data?: { message?: string; }; diff --git a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts index 9c51295bc..cf8b50860 100644 --- a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts +++ b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts @@ -46,8 +46,11 @@ function useMsTeamsConnectionStatus( // This is a normal response for a tenant that doesn't have // ms_teams_tenant_id set on it, meaning it's not connected to MS Teams, // so we give it a "disconnected" status instead of an error status. + const responseStatus = authRes.response?.status; if ( - authRes.code === "ERR_BAD_REQUEST" && + typeof responseStatus === "number" && + responseStatus >= 400 && + responseStatus < 500 && authRes.response?.data?.message === t("msTeamsTenantIdNotSet") ) { return setConnectionStatus("disconnected"); diff --git a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts index b374fdf23..5eff128da 100644 --- a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts +++ b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts @@ -53,8 +53,11 @@ function useSlackConnectionStatus( // This is a normal response for a tenant that doesn't have an access // token set on it, meaning it's not connected to Slack, so we // give it a "disconnected" status instead of an error status. + const responseStatus = authRes.response?.status; if ( - authRes.code === "ERR_BAD_REQUEST" && + typeof responseStatus === "number" && + responseStatus >= 400 && + responseStatus < 500 && authRes.response?.data?.message === t("slackAccessTokenNotSet") ) { return setConnectionStatus("disconnected"); diff --git a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx index abcd2ba17..4b04dd00a 100644 --- a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx +++ b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx @@ -61,6 +61,25 @@ describe("useMsTeamsConnectionStatus", () => { ); }); + it("sets status to disconnected when the tenant id is not set (4xx)", async () => { + const knock = buildMockKnock(() => + Promise.resolve({ + response: { + status: 400, + data: { message: "msTeamsTenantIdNotSet" }, + }, + }), + ); + + const { result } = renderHook(() => + useMsTeamsConnectionStatus(knock, channelId, tenantId), + ); + + await waitFor(() => + expect(result.current.connectionStatus).toBe("disconnected"), + ); + }); + it("sets status to error when authCheck throws", async () => { const knock = buildMockKnock(() => Promise.reject(new Error("failure"))); diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index 93ad5296e..18bbe958e 100644 --- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx +++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx @@ -54,6 +54,25 @@ describe("useSlackConnectionStatus", () => { ); }); + it("sets status to disconnected when the access token is not set (4xx)", async () => { + const knock = buildMockKnock(() => + Promise.resolve({ + response: { + status: 400, + data: { message: "slackAccessTokenNotSet" }, + }, + }), + ); + + const { result } = renderHook(() => + useSlackConnectionStatus(knock, channelId, tenantId), + ); + + await waitFor(() => + expect(result.current.connectionStatus).toBe("disconnected"), + ); + }); + it("sets error status and label for slack error", async () => { const knock = buildMockKnock(() => Promise.resolve({ connection: { ok: false, error: "account_inactive" } }), From 84ac998b13cb03c47fbe1000d8766b80a4cd96b3 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 11:04:02 -0500 Subject: [PATCH 8/9] fix(client): honor Retry-After header and restore retry jitter axios-retry's exponentialDelay used Math.max(backoff, retryAfter(error)) and added up to 20% jitter. The fetch port used a fixed exponential backoff and dropped both, so a 429/503 carrying Retry-After was retried on the short backoff (exhausting retries in ~1.4s) instead of waiting the server-specified window. Parse Retry-After (seconds or HTTP date) and take the max with the jittered backoff, restoring the old behavior. --- packages/client/src/api.ts | 32 ++++++++++++++++++++++--- packages/client/test/api.test.ts | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 9b265af3b..9e9c39d46 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -142,6 +142,8 @@ class ApiClient { // 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++) { + let retryAfterMs = 0; + try { const response = await this.fetchClient( this.buildUrl(req.url, req.params), @@ -149,6 +151,7 @@ class ApiClient { ); if (!response.ok && this.canRetryRequest({ response })) { + retryAfterMs = this.getRetryAfterMs(response); // This response is discarded before the next attempt, so read its // body directly (no clone) to drain it and release the connection. lastError = new ApiRequestError( @@ -167,7 +170,7 @@ class ApiClient { } if (attempt < 3) { - await this.delay(this.getRetryDelay(attempt + 1)); + await this.delay(this.getRetryDelay(attempt + 1, retryAfterMs)); } } @@ -261,8 +264,31 @@ class ApiClient { return new Promise((resolve) => setTimeout(resolve, ms)); } - private getRetryDelay(retryCount: number) { - return Math.min(100 * 2 ** retryCount, 30_000); + private getRetryDelay(retryCount: number, retryAfterMs = 0) { + const backoff = Math.min(100 * 2 ** retryCount, 30_000); + const delay = Math.max(backoff, retryAfterMs); + // Add up to 20% jitter so retries from many clients don't synchronize. + return delay + delay * 0.2 * Math.random(); + } + + private getRetryAfterMs(response: Response) { + const header = response.headers.get("retry-after"); + if (!header) { + return 0; + } + + // `Retry-After` is either a number of seconds or an HTTP date. + const seconds = Number(header); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + + const dateMs = new Date(header).valueOf(); + if (Number.isNaN(dateMs)) { + return 0; + } + + return Math.max(0, dateMs - Date.now()); } private getFetchClient(): FetchClient { diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 24dbe05bb..5ca53c745 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -489,6 +489,46 @@ describe("API Client", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + test("honors the Retry-After header before retrying", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Rate limited" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "2", + }, + }), + ) + .mockResolvedValue(createJsonResponse({ success: true })); + setFetchMock(apiClient, fetchMock); + + // Capture the backoff durations instead of skipping them. + const delays: number[] = []; + (apiClient as unknown as Record).delay = vi.fn( + (ms: number) => { + delays.push(ms); + return Promise.resolve(); + }, + ); + + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + }); + + expect(response.statusCode).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(2); + // Retry-After: 2 seconds -> wait at least 2000ms before retrying. + expect(delays[0]).toBeGreaterThanOrEqual(2000); + }); + test("does not retry on client errors (4xx except 429)", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", From eb1b42bfee4ae144f99c32c3813c8a525d73b5d5 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Thu, 18 Jun 2026 11:34:24 -0500 Subject: [PATCH 9/9] chore: add changeset for the react-core connection-status fix --- .changeset/react-core-connection-status.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/react-core-connection-status.md diff --git a/.changeset/react-core-connection-status.md b/.changeset/react-core-connection-status.md new file mode 100644 index 000000000..d54a8829e --- /dev/null +++ b/.changeset/react-core-connection-status.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react-core": patch +--- + +Keep Slack and MS Teams connection-status detection working with the new fetch-based `@knocklabs/client` transport by reading the HTTP response status instead of the previous axios-specific error `code`. The exported `AuthCheckResult` type no longer includes `code` and now exposes `response.status`.