217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
retryWithBackoff,
|
|
isRetryableError,
|
|
calculateBackoffDelay,
|
|
sleep,
|
|
DEFAULT_RETRY_CONFIG,
|
|
} from "./chat-resilience";
|
|
|
|
describe("Chat Resilience", () => {
|
|
describe("calculateBackoffDelay", () => {
|
|
it("should calculate exponential backoff delays", () => {
|
|
expect(calculateBackoffDelay(1, DEFAULT_RETRY_CONFIG)).toBe(1000);
|
|
expect(calculateBackoffDelay(2, DEFAULT_RETRY_CONFIG)).toBe(2000);
|
|
expect(calculateBackoffDelay(3, DEFAULT_RETRY_CONFIG)).toBe(4000);
|
|
});
|
|
|
|
it("should respect maxDelayMs", () => {
|
|
const config = { ...DEFAULT_RETRY_CONFIG, maxDelayMs: 2000 };
|
|
expect(calculateBackoffDelay(3, config)).toBe(2000);
|
|
});
|
|
});
|
|
|
|
describe("isRetryableError", () => {
|
|
it("should identify timeout errors as retryable", () => {
|
|
expect(isRetryableError(new Error("timeout"))).toBe(true);
|
|
expect(isRetryableError(new Error("ECONNRESET"))).toBe(true);
|
|
});
|
|
|
|
it("should identify network errors as retryable", () => {
|
|
expect(isRetryableError(new Error("ECONNREFUSED"))).toBe(true);
|
|
expect(isRetryableError(new Error("ENOTFOUND"))).toBe(true);
|
|
});
|
|
|
|
it("should identify 5xx errors as retryable", () => {
|
|
const err = new Error("Server error");
|
|
(err as any).status = 503;
|
|
expect(isRetryableError(err)).toBe(true);
|
|
});
|
|
|
|
it("should identify gateway errors as retryable", () => {
|
|
const err502 = new Error("Bad Gateway");
|
|
(err502 as any).status = 502;
|
|
expect(isRetryableError(err502)).toBe(true);
|
|
|
|
const err504 = new Error("Gateway Timeout");
|
|
(err504 as any).status = 504;
|
|
expect(isRetryableError(err504)).toBe(true);
|
|
});
|
|
|
|
it("should identify unavailable service as retryable", () => {
|
|
expect(isRetryableError(new Error("service unavailable"))).toBe(true);
|
|
});
|
|
|
|
it("should not identify 4xx errors as retryable", () => {
|
|
const err = new Error("Not found");
|
|
(err as any).status = 404;
|
|
expect(isRetryableError(err)).toBe(false);
|
|
});
|
|
|
|
it("should handle null/undefined errors", () => {
|
|
expect(isRetryableError(null)).toBe(false);
|
|
expect(isRetryableError(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("retryWithBackoff", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("should succeed on first attempt", async () => {
|
|
const fn = vi.fn().mockResolvedValue("success");
|
|
const result = await retryWithBackoff(fn);
|
|
expect(result).toBe("success");
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should retry on failure and eventually succeed", async () => {
|
|
const fn = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("timeout"))
|
|
.mockResolvedValueOnce("success");
|
|
|
|
const onRetry = vi.fn();
|
|
const promise = retryWithBackoff(fn, DEFAULT_RETRY_CONFIG, onRetry);
|
|
|
|
// Advance time for first retry
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const result = await promise;
|
|
|
|
expect(result).toBe("success");
|
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should fail after max attempts", async () => {
|
|
vi.useRealTimers();
|
|
const fn = vi.fn().mockRejectedValue(new Error("timeout"));
|
|
const onRetry = vi.fn();
|
|
|
|
const promise = retryWithBackoff(
|
|
fn,
|
|
{ ...DEFAULT_RETRY_CONFIG, maxAttempts: 2, baseDelayMs: 10, maxDelayMs: 100 },
|
|
onRetry
|
|
);
|
|
|
|
await expect(promise).rejects.toThrow("timeout");
|
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("should throw immediately for non-retryable errors", async () => {
|
|
vi.useRealTimers();
|
|
const fn = vi.fn().mockRejectedValue(new Error("Not found"));
|
|
const onRetry = vi.fn();
|
|
|
|
const promise = retryWithBackoff(fn, DEFAULT_RETRY_CONFIG, (attempt, error) => {
|
|
if (error.message === "Not found") {
|
|
throw error;
|
|
}
|
|
onRetry(attempt, error);
|
|
});
|
|
|
|
await expect(promise).rejects.toThrow("Not found");
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
expect(onRetry).not.toHaveBeenCalled();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("should call onRetry callback with attempt number and error", async () => {
|
|
const fn = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("timeout"))
|
|
.mockResolvedValueOnce("success");
|
|
|
|
const onRetry = vi.fn();
|
|
const promise = retryWithBackoff(fn, DEFAULT_RETRY_CONFIG, onRetry);
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await promise;
|
|
|
|
expect(onRetry).toHaveBeenCalledWith(1, expect.objectContaining({ message: "timeout" }));
|
|
});
|
|
});
|
|
|
|
describe("sleep", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("should sleep for specified duration", async () => {
|
|
const promise = sleep(1000);
|
|
expect(promise).toBeDefined();
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await promise;
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Integration: Chat Resilience Flow", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("should retry chat on timeout and recover", async () => {
|
|
let attempts = 0;
|
|
const chatFn = vi.fn().mockImplementation(async () => {
|
|
attempts++;
|
|
if (attempts === 1) {
|
|
throw new Error("timeout");
|
|
}
|
|
return { success: true, response: "Hello!" };
|
|
});
|
|
|
|
const onRetry = vi.fn();
|
|
const promise = retryWithBackoff(chatFn, DEFAULT_RETRY_CONFIG, onRetry);
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual({ success: true, response: "Hello!" });
|
|
expect(chatFn).toHaveBeenCalledTimes(2);
|
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should handle multiple retries with exponential backoff", async () => {
|
|
vi.useRealTimers();
|
|
let attempts = 0;
|
|
const chatFn = vi.fn().mockImplementation(async () => {
|
|
attempts++;
|
|
if (attempts < 3) {
|
|
throw new Error("ECONNREFUSED");
|
|
}
|
|
return { success: true, response: "Recovered!" };
|
|
});
|
|
|
|
const onRetry = vi.fn();
|
|
const promise = retryWithBackoff(
|
|
chatFn,
|
|
{ ...DEFAULT_RETRY_CONFIG, maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100 },
|
|
onRetry
|
|
);
|
|
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual({ success: true, response: "Recovered!" });
|
|
expect(chatFn).toHaveBeenCalledTimes(3);
|
|
expect(onRetry).toHaveBeenCalledTimes(2);
|
|
vi.useFakeTimers();
|
|
});
|
|
});
|
|
});
|