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
This commit is contained in:
288
apps/web/src/lib/auth/fetch-with-retry.test.ts
Normal file
288
apps/web/src/lib/auth/fetch-with-retry.test.ts
Normal file
@@ -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<void> => {
|
||||
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<ArrayBuffer>,
|
||||
blob: vi.fn() as unknown as () => Promise<Blob>,
|
||||
formData: vi.fn() as unknown as () => Promise<FormData>,
|
||||
json: vi.fn() as unknown as () => Promise<unknown>,
|
||||
text: vi.fn() as unknown as () => Promise<string>,
|
||||
bytes: vi.fn() as unknown as () => Promise<Uint8Array>,
|
||||
} 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<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", 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
// 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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
116
apps/web/src/lib/auth/fetch-with-retry.ts
Normal file
116
apps/web/src/lib/auth/fetch-with-retry.ts
Normal file
@@ -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<Response> {
|
||||
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");
|
||||
}
|
||||
9
apps/web/src/lib/auth/sleep.ts
Normal file
9
apps/web/src/lib/auth/sleep.ts
Normal file
@@ -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<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user