Files
Dorod-Sky/skyvern-ts/client/tests/unit/fetcher/requestWithRetries.test.ts
2025-10-23 20:14:59 -06:00

251 lines
9.5 KiB
TypeScript

import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries";
describe("requestWithRetries", () => {
let mockFetch: import("vitest").Mock;
let originalMathRandom: typeof Math.random;
let setTimeoutSpy: import("vitest").MockInstance;
beforeEach(() => {
mockFetch = vi.fn();
originalMathRandom = Math.random;
// Mock Math.random for consistent jitter
Math.random = vi.fn(() => 0.5);
vi.useFakeTimers({
toFake: [
"setTimeout",
"clearTimeout",
"setInterval",
"clearInterval",
"setImmediate",
"clearImmediate",
"Date",
"performance",
"requestAnimationFrame",
"cancelAnimationFrame",
"requestIdleCallback",
"cancelIdleCallback",
],
});
});
afterEach(() => {
Math.random = originalMathRandom;
vi.clearAllMocks();
vi.clearAllTimers();
});
it("should retry on retryable status codes", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
const retryableStatuses = [408, 429, 500, 502];
let callCount = 0;
mockFetch.mockImplementation(async () => {
if (callCount < retryableStatuses.length) {
return new Response("", { status: retryableStatuses[callCount++] });
}
return new Response("", { status: 200 });
});
const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length);
await vi.runAllTimersAsync();
const response = await responsePromise;
expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1);
expect(response.status).toBe(200);
});
it("should respect maxRetries limit", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
const maxRetries = 2;
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
await vi.runAllTimersAsync();
const response = await responsePromise;
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
expect(response.status).toBe(500);
});
it("should not retry on success status codes", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
const successStatuses = [200, 201, 202];
for (const status of successStatuses) {
mockFetch.mockReset();
setTimeoutSpy.mockClear();
mockFetch.mockResolvedValueOnce(new Response("", { status }));
const responsePromise = requestWithRetries(() => mockFetch(), 3);
await vi.runAllTimersAsync();
await responsePromise;
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).not.toHaveBeenCalled();
}
});
it("should apply correct exponential backoff with jitter", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
const maxRetries = 3;
const expectedDelays = [1000, 2000, 4000];
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
await vi.runAllTimersAsync();
await responsePromise;
// Verify setTimeout calls
expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length);
expectedDelays.forEach((delay, index) => {
expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay);
});
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
});
it("should handle concurrent retries independently", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
mockFetch
.mockResolvedValueOnce(new Response("", { status: 500 }))
.mockResolvedValueOnce(new Response("", { status: 500 }))
.mockResolvedValueOnce(new Response("", { status: 200 }))
.mockResolvedValueOnce(new Response("", { status: 200 }));
const promise1 = requestWithRetries(() => mockFetch(), 1);
const promise2 = requestWithRetries(() => mockFetch(), 1);
await vi.runAllTimersAsync();
const [response1, response2] = await Promise.all([promise1, promise2]);
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
});
it("should respect retry-after header with seconds value", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
mockFetch
.mockResolvedValueOnce(
new Response("", {
status: 429,
headers: new Headers({ "retry-after": "5" }),
}),
)
.mockResolvedValueOnce(new Response("", { status: 200 }));
const responsePromise = requestWithRetries(() => mockFetch(), 1);
await vi.runAllTimersAsync();
const response = await responsePromise;
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); // 5 seconds = 5000ms
expect(response.status).toBe(200);
});
it("should respect retry-after header with HTTP date value", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
const futureDate = new Date(Date.now() + 3000); // 3 seconds from now
mockFetch
.mockResolvedValueOnce(
new Response("", {
status: 429,
headers: new Headers({ "retry-after": futureDate.toUTCString() }),
}),
)
.mockResolvedValueOnce(new Response("", { status: 200 }));
const responsePromise = requestWithRetries(() => mockFetch(), 1);
await vi.runAllTimersAsync();
const response = await responsePromise;
// Should use the date-based delay (approximately 3000ms, but with jitter)
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
const actualDelay = setTimeoutSpy.mock.calls[0][1];
expect(actualDelay).toBeGreaterThan(2000);
expect(actualDelay).toBeLessThan(4000);
expect(response.status).toBe(200);
});
it("should respect x-ratelimit-reset header", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
const resetTime = Math.floor((Date.now() + 4000) / 1000); // 4 seconds from now in Unix timestamp
mockFetch
.mockResolvedValueOnce(
new Response("", {
status: 429,
headers: new Headers({ "x-ratelimit-reset": resetTime.toString() }),
}),
)
.mockResolvedValueOnce(new Response("", { status: 200 }));
const responsePromise = requestWithRetries(() => mockFetch(), 1);
await vi.runAllTimersAsync();
const response = await responsePromise;
// Should use the x-ratelimit-reset delay (approximately 4000ms, but with positive jitter)
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
const actualDelay = setTimeoutSpy.mock.calls[0][1];
expect(actualDelay).toBeGreaterThan(3000);
expect(actualDelay).toBeLessThan(6000);
expect(response.status).toBe(200);
});
it("should cap delay at MAX_RETRY_DELAY for large header values", async () => {
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
process.nextTick(callback);
return null as any;
});
mockFetch
.mockResolvedValueOnce(
new Response("", {
status: 429,
headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms)
}),
)
.mockResolvedValueOnce(new Response("", { status: 200 }));
const responsePromise = requestWithRetries(() => mockFetch(), 1);
await vi.runAllTimersAsync();
const response = await responsePromise;
// Should be capped at MAX_RETRY_DELAY (60000ms) with jitter applied
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); // Exactly MAX_RETRY_DELAY since jitter with 0.5 random keeps it at 60000
expect(response.status).toBe(200);
});
});