fetchWithRetry now clamps maxRetries>=0, baseDelayMs>=100, backoffFactor>=1 to prevent infinite loops or zero-delay hammering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
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 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<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 all environments", async (): Promise<void> => {
|
|
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<void> => {
|
|
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<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);
|
|
});
|
|
|
|
describe("RetryOptions value clamping", (): void => {
|
|
it("should clamp negative maxRetries to 0 (no retries)", async (): Promise<void> => {
|
|
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<void> => {
|
|
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<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: 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<void> => {
|
|
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]);
|
|
});
|
|
});
|
|
});
|