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(); }); }); });