Files
GoClaw/server/chat-resilience.test.ts
2026-03-26 05:41:44 -04:00

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