From c233d97ba0e8cf3b457bd9eb1bead3c37d805032 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 12:19:46 -0600 Subject: [PATCH] feat(#417): add fetchWithRetry with exponential backoff for auth Retries network and server errors up to 3 times with exponential backoff (1s, 2s, 4s). Non-retryable errors fail immediately. Refs #417 --- .../web/src/lib/auth/fetch-with-retry.test.ts | 288 ++++++++++++++++++ apps/web/src/lib/auth/fetch-with-retry.ts | 116 +++++++ apps/web/src/lib/auth/sleep.ts | 9 + 3 files changed, 413 insertions(+) create mode 100644 apps/web/src/lib/auth/fetch-with-retry.test.ts create mode 100644 apps/web/src/lib/auth/fetch-with-retry.ts create mode 100644 apps/web/src/lib/auth/sleep.ts diff --git a/apps/web/src/lib/auth/fetch-with-retry.test.ts b/apps/web/src/lib/auth/fetch-with-retry.test.ts new file mode 100644 index 0000000..1e79e60 --- /dev/null +++ b/apps/web/src/lib/auth/fetch-with-retry.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** Recorded delays passed to the mocked sleep function. */ +const recordedDelays: number[] = []; + +// Mock the sleep module to resolve instantly and record delays +vi.mock("./sleep", () => ({ + sleep: vi.fn((ms: number): Promise => { + recordedDelays.push(ms); + return Promise.resolve(); + }), +})); + +import { fetchWithRetry } from "./fetch-with-retry"; +import { sleep } from "./sleep"; + +/** + * Helper: create a minimal Response object for mocking fetch. + */ +function mockResponse(status: number, ok?: boolean): Response { + return { + ok: ok ?? (status >= 200 && status < 300), + status, + statusText: status === 200 ? "OK" : "Error", + headers: new Headers(), + redirected: false, + type: "basic" as ResponseType, + url: "", + body: null, + bodyUsed: false, + clone: vi.fn() as unknown as () => Response, + arrayBuffer: vi.fn() as unknown as () => Promise, + blob: vi.fn() as unknown as () => Promise, + formData: vi.fn() as unknown as () => Promise, + json: vi.fn() as unknown as () => Promise, + text: vi.fn() as unknown as () => Promise, + bytes: vi.fn() as unknown as () => Promise, + } as Response; +} + +describe("fetchWithRetry", (): void => { + const originalFetch = global.fetch; + const originalEnv = process.env.NODE_ENV; + const sleepMock = vi.mocked(sleep); + + beforeEach((): void => { + global.fetch = vi.fn(); + recordedDelays.length = 0; + sleepMock.mockClear(); + }); + + afterEach((): void => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + process.env.NODE_ENV = originalEnv; + }); + + it("should succeed on first attempt without retrying", async (): Promise => { + const okResponse = mockResponse(200); + vi.mocked(global.fetch).mockResolvedValueOnce(okResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(okResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sleepMock).not.toHaveBeenCalled(); + }); + + it("should retry on network error and succeed on 2nd attempt", async (): Promise => { + const okResponse = mockResponse(200); + vi.mocked(global.fetch) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(okResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(okResponse); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(sleepMock).toHaveBeenCalledTimes(1); + }); + + it("should retry on server error (500) and succeed on 3rd attempt", async (): Promise => { + const serverError = mockResponse(500); + const okResponse = mockResponse(200); + + vi.mocked(global.fetch) + .mockResolvedValueOnce(serverError) + .mockResolvedValueOnce(serverError) + .mockResolvedValueOnce(okResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(okResponse); + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(sleepMock).toHaveBeenCalledTimes(2); + }); + + it("should give up after maxRetries and throw the last error", async (): Promise => { + const networkError = new TypeError("Failed to fetch"); + vi.mocked(global.fetch) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError); + + await expect( + fetchWithRetry("https://api.example.com/auth/config", undefined, { + maxRetries: 3, + baseDelayMs: 1000, + }), + ).rejects.toThrow("Failed to fetch"); + + // 1 initial + 3 retries = 4 total attempts + expect(global.fetch).toHaveBeenCalledTimes(4); + // Sleep called for the 3 retries (not after the final failure) + expect(sleepMock).toHaveBeenCalledTimes(3); + }); + + it("should NOT retry on non-retryable errors (401)", async (): Promise => { + const unauthorizedResponse = mockResponse(401); + vi.mocked(global.fetch).mockResolvedValueOnce(unauthorizedResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(unauthorizedResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sleepMock).not.toHaveBeenCalled(); + }); + + it("should NOT retry on non-retryable errors (403)", async (): Promise => { + const forbiddenResponse = mockResponse(403); + vi.mocked(global.fetch).mockResolvedValueOnce(forbiddenResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(forbiddenResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sleepMock).not.toHaveBeenCalled(); + }); + + it("should NOT retry on non-retryable errors (429)", async (): Promise => { + const rateLimitedResponse = mockResponse(429); + vi.mocked(global.fetch).mockResolvedValueOnce(rateLimitedResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config"); + + expect(result).toBe(rateLimitedResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sleepMock).not.toHaveBeenCalled(); + }); + + it("should respect custom maxRetries option", async (): Promise => { + const networkError = new TypeError("Failed to fetch"); + vi.mocked(global.fetch) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError); + + await expect( + fetchWithRetry("https://api.example.com/auth/config", undefined, { + maxRetries: 1, + baseDelayMs: 50, + }), + ).rejects.toThrow("Failed to fetch"); + + // 1 initial + 1 retry = 2 total attempts + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(sleepMock).toHaveBeenCalledTimes(1); + }); + + it("should respect custom baseDelayMs option", async (): Promise => { + const okResponse = mockResponse(200); + vi.mocked(global.fetch) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(okResponse); + + await fetchWithRetry("https://api.example.com/auth/config", undefined, { + baseDelayMs: 500, + }); + + // First retry delay should be 500ms (baseDelayMs * 2^0) + expect(recordedDelays[0]).toBe(500); + }); + + it("should use exponential backoff with doubling delays (1s, 2s, 4s)", async (): Promise => { + const networkError = new TypeError("Failed to fetch"); + const okResponse = mockResponse(200); + + vi.mocked(global.fetch) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockResolvedValueOnce(okResponse); + + const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, { + baseDelayMs: 1000, + backoffFactor: 2, + }); + + expect(result).toBe(okResponse); + expect(global.fetch).toHaveBeenCalledTimes(4); + + // Verify exponential backoff: 1000 * 2^0, 1000 * 2^1, 1000 * 2^2 + expect(recordedDelays).toEqual([1000, 2000, 4000]); + }); + + it("should log retry attempts in development mode", async (): Promise => { + process.env.NODE_ENV = "development"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); + + const okResponse = mockResponse(200); + vi.mocked(global.fetch) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(okResponse); + + await fetchWithRetry("https://api.example.com/auth/config"); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[Auth] Retry 1/3"), + ); + + warnSpy.mockRestore(); + }); + + it("should NOT log retry attempts in production mode", async (): Promise => { + process.env.NODE_ENV = "production"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); + + const okResponse = mockResponse(200); + vi.mocked(global.fetch) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(okResponse); + + await fetchWithRetry("https://api.example.com/auth/config"); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("should forward RequestInit options to fetch", async (): Promise => { + const okResponse = mockResponse(200); + vi.mocked(global.fetch).mockResolvedValueOnce(okResponse); + + const requestOptions: RequestInit = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: "abc" }), + }; + + await fetchWithRetry("https://api.example.com/auth/config", requestOptions); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/auth/config", + requestOptions, + ); + }); + + it("should not retry on non-retryable thrown errors", async (): Promise => { + // An Error that parseAuthError classifies as non-retryable (e.g., "Unauthorized") + const nonRetryableError = new Error("Unauthorized"); + vi.mocked(global.fetch).mockRejectedValueOnce(nonRetryableError); + + await expect( + fetchWithRetry("https://api.example.com/auth/config"), + ).rejects.toThrow("Unauthorized"); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sleepMock).not.toHaveBeenCalled(); + }); + + it("should return last non-ok response when server errors exhaust retries", async (): Promise => { + const serverError = mockResponse(500); + vi.mocked(global.fetch) + .mockResolvedValueOnce(serverError) + .mockResolvedValueOnce(serverError) + .mockResolvedValueOnce(serverError); + + const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, { + maxRetries: 2, + }); + + expect(result.status).toBe(500); + // 1 initial + 2 retries = 3 total attempts + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(sleepMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/auth/fetch-with-retry.ts b/apps/web/src/lib/auth/fetch-with-retry.ts new file mode 100644 index 0000000..3ef6522 --- /dev/null +++ b/apps/web/src/lib/auth/fetch-with-retry.ts @@ -0,0 +1,116 @@ +/** + * Fetch wrapper with automatic retry and exponential backoff for auth requests. + * + * Only retries errors classified as retryable by {@link parseAuthError}: + * `network_error` and `server_error`. Non-retryable errors (401, 403, 429, etc.) + * are returned or thrown immediately without retry. + */ + +import { parseAuthError } from "./auth-errors"; +import { sleep } from "./sleep"; + +export interface RetryOptions { + /** Maximum number of retries after the initial attempt. Default: 3. */ + maxRetries?: number; + /** Base delay in milliseconds before the first retry. Default: 1000. */ + baseDelayMs?: number; + /** Multiplicative factor applied to the delay after each retry. Default: 2. */ + backoffFactor?: number; +} + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_BASE_DELAY_MS = 1000; +const DEFAULT_BACKOFF_FACTOR = 2; + +/** + * Compute the backoff delay for a given retry attempt. + * + * @param attempt - Zero-based retry index (0 = first retry) + * @param baseDelayMs - Starting delay in milliseconds + * @param backoffFactor - Multiplicative factor per retry + * @returns Delay in milliseconds + */ +function computeDelay(attempt: number, baseDelayMs: number, backoffFactor: number): number { + return baseDelayMs * Math.pow(backoffFactor, attempt); +} + +/** + * Fetch a URL with automatic retries and exponential backoff for retryable errors. + * + * - Network errors (fetch throws `TypeError`) are retried if classified as retryable. + * - HTTP error responses (e.g. 500, 502, 503) are retried if the status maps to a + * retryable error code. + * - Non-retryable errors (401, 403, 429) are returned or thrown immediately. + * - On exhausted retries for network errors, the last error is re-thrown. + * - On exhausted retries for HTTP errors, the last response is returned. + * + * @param url - The URL to fetch + * @param options - Standard `RequestInit` options forwarded to `fetch` + * @param retryOptions - Controls retry behaviour (max retries, delay, backoff) + * @returns The successful (or final non-retryable) `Response` + */ +export async function fetchWithRetry( + url: string, + options?: RequestInit, + retryOptions?: RetryOptions, +): Promise { + const maxRetries = retryOptions?.maxRetries ?? DEFAULT_MAX_RETRIES; + const baseDelayMs = retryOptions?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; + const backoffFactor = retryOptions?.backoffFactor ?? DEFAULT_BACKOFF_FACTOR; + + let lastError: unknown = null; + let lastResponse: Response | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, options); + + if (response.ok) { + return response; + } + + // Non-ok response: check if we should retry based on status code + const parsed = parseAuthError({ status: response.status }); + + if (!parsed.retryable || attempt === maxRetries) { + return response; + } + + // Retryable HTTP error with retries remaining + lastResponse = response; + const delay = computeDelay(attempt, baseDelayMs, backoffFactor); + + if (process.env.NODE_ENV === "development") { + console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`); + } + + await sleep(delay); + } catch (error: unknown) { + const parsed = parseAuthError(error); + + if (!parsed.retryable || attempt === maxRetries) { + throw error; + } + + // Retryable thrown error with retries remaining + lastError = error; + const delay = computeDelay(attempt, baseDelayMs, backoffFactor); + + if (process.env.NODE_ENV === "development") { + console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`); + } + + await sleep(delay); + } + } + + // Should not be reached due to the loop logic, but satisfy TypeScript + if (lastError) { + throw lastError; + } + if (lastResponse) { + return lastResponse; + } + + throw new Error("fetchWithRetry: unexpected state"); +} diff --git a/apps/web/src/lib/auth/sleep.ts b/apps/web/src/lib/auth/sleep.ts new file mode 100644 index 0000000..3e717d2 --- /dev/null +++ b/apps/web/src/lib/auth/sleep.ts @@ -0,0 +1,9 @@ +/** + * Wait for the specified number of milliseconds. + * + * Extracted to a separate module to enable clean test mocking + * without fake timers. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}