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 sleepMock = vi.mocked(sleep); beforeEach((): void => { global.fetch = vi.fn(); recordedDelays.length = 0; sleepMock.mockClear(); }); afterEach((): void => { vi.restoreAllMocks(); global.fetch = originalFetch; }); 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 all environments", async (): Promise => { const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); 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 log retry attempts for HTTP errors", async (): Promise => { const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); const serverError = mockResponse(500); const okResponse = mockResponse(200); vi.mocked(global.fetch).mockResolvedValueOnce(serverError).mockResolvedValueOnce(okResponse); await fetchWithRetry("https://api.example.com/auth/config"); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("[Auth] Retry 1/3 after HTTP 500") ); 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); }); describe("RetryOptions value clamping", (): void => { it("should clamp negative maxRetries to 0 (no retries)", async (): Promise => { const serverError = mockResponse(500); vi.mocked(global.fetch).mockResolvedValueOnce(serverError); const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, { maxRetries: -5, }); // maxRetries clamped to 0 means only the initial attempt, no retries expect(result.status).toBe(500); expect(global.fetch).toHaveBeenCalledTimes(1); expect(sleepMock).not.toHaveBeenCalled(); }); it("should clamp fractional maxRetries by flooring", async (): Promise => { const networkError = new TypeError("Failed to fetch"); const okResponse = mockResponse(200); vi.mocked(global.fetch).mockRejectedValueOnce(networkError).mockResolvedValueOnce(okResponse); const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, { maxRetries: 1.9, baseDelayMs: 100, }); // 1.9 floors to 1, so 1 initial + 1 retry = 2 attempts expect(result).toBe(okResponse); expect(global.fetch).toHaveBeenCalledTimes(2); expect(sleepMock).toHaveBeenCalledTimes(1); }); it("should clamp baseDelayMs below 100 up to 100", 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: 0, }); // baseDelayMs clamped to 100, so first retry delay = 100 * 2^0 = 100 expect(recordedDelays[0]).toBe(100); }); it("should clamp backoffFactor below 1 up to 1 (linear delay)", async (): Promise => { const networkError = new TypeError("Failed to fetch"); const okResponse = mockResponse(200); vi.mocked(global.fetch) .mockRejectedValueOnce(networkError) .mockRejectedValueOnce(networkError) .mockResolvedValueOnce(okResponse); await fetchWithRetry("https://api.example.com/auth/config", undefined, { maxRetries: 3, baseDelayMs: 200, backoffFactor: 0, }); // backoffFactor clamped to 1, so delays are 200*1^0=200, 200*1^1=200 (constant) expect(recordedDelays).toEqual([200, 200]); }); }); });