/** * KHAELOR * File: tests/anthropic/retry.test.ts * Description: Retry policy tests — exponential backoff with jitter, retry-after, retryable-only, before-first-token-only. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { APIError } from "@anthropic-ai/sdk"; import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages"; import { AnthropicModelClient, backoffDelayMs } from "../../src/anthropic/client.js"; import { ModelError } from "../../src/anthropic/errors.js"; import type { ModelEvent, ModelRequest } from "../../src/anthropic/types.js"; import { asRawStream, messageStart, simpleTextStream, textBlockStart, textDelta } from "./fixtures.js"; const REQUEST: ModelRequest = { model: "claude-sonnet-5", system: [{ name: "identity", text: "You are KHAELOR." }], messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], tools: [], maxOutputTokens: 64, }; function apiError(status: number, message: string, headers?: Record): APIError { return APIError.generate( status, { error: { type: "error", message } }, message, new Headers(headers ?? {}), ); } async function collectAll(iterable: AsyncIterable): Promise { const out: ModelEvent[] = []; for await (const event of iterable) out.push(event); return out; } describe("backoffDelayMs", () => { const opts = { baseMs: 500, capMs: 8_000, random: () => 0.5 }; it("grows exponentially with equal jitter and respects the cap", () => { expect(backoffDelayMs(1, opts)).toBe(375); // 500/2 + 0.5·250 expect(backoffDelayMs(2, opts)).toBe(750); expect(backoffDelayMs(3, opts)).toBe(1_500); expect(backoffDelayMs(10, opts)).toBe(6_000); // capped raw 8000 → 4000 + 2000 }); it("jitter spans [raw/2, raw]", () => { expect(backoffDelayMs(1, { ...opts, random: () => 0 })).toBe(250); expect(backoffDelayMs(1, { ...opts, random: () => 1 })).toBe(500); }); it("a server retry-after hint overrides the exponential delay, capped at 30 s", () => { expect(backoffDelayMs(1, { ...opts, retryAfterMs: 7_000 })).toBe(7_000); expect(backoffDelayMs(1, { ...opts, retryAfterMs: 120_000 })).toBe(30_000); }); }); describe("AnthropicModelClient retry policy", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("retries retryable errors with exponential backoff before the first token (fake timers)", async () => { const factory = vi .fn() .mockRejectedValueOnce(apiError(529, "overloaded_error: Overloaded")) .mockRejectedValueOnce(apiError(500, "internal server error")) .mockImplementationOnce(() => Promise.resolve(asRawStream(simpleTextStream()))); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory, random: () => 1, // deterministic: delay = full raw backoff }); const promise = collectAll(client.stream(REQUEST)); await vi.advanceTimersByTimeAsync(0); expect(factory).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(499); // first backoff = 500·2⁰ = 500 ms expect(factory).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(factory).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(999); // second backoff = 500·2¹ = 1000 ms expect(factory).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1); expect(factory).toHaveBeenCalledTimes(3); const events = await promise; expect(events.map((e) => e.type)).toEqual([ "started", "text-delta", "text-delta", "text-block-completed", "completed", ]); }); it("honors a 429 retry-after hint as the backoff delay", async () => { const factory = vi .fn() .mockRejectedValueOnce(apiError(429, "rate limited", { "retry-after": "7" })) .mockImplementationOnce(() => Promise.resolve(asRawStream(simpleTextStream()))); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); const promise = collectAll(client.stream(REQUEST)); await vi.advanceTimersByTimeAsync(6_999); expect(factory).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(factory).toHaveBeenCalledTimes(2); await expect(promise).resolves.toHaveLength(5); }); it("does not retry fatal errors", async () => { const factory = vi.fn().mockRejectedValue(apiError(401, "invalid x-api-key")); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); await expect(collectAll(client.stream(REQUEST))).rejects.toSatisfy( (e: unknown) => e instanceof ModelError && e.kind === "auth", ); expect(factory).toHaveBeenCalledTimes(1); }); it("does not retry once events have been yielded (mid-stream failure surfaces)", async () => { async function* failsMidStream(): AsyncGenerator { yield messageStart(); yield textBlockStart(0); yield textDelta(0, "partial"); throw apiError(500, "stream broke"); } const factory = vi.fn().mockImplementation(() => Promise.resolve(failsMidStream())); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); const seen: ModelEvent[] = []; await expect( (async () => { for await (const event of client.stream(REQUEST)) seen.push(event); })(), ).rejects.toSatisfy((e: unknown) => e instanceof ModelError && e.kind === "retryable"); expect(factory).toHaveBeenCalledTimes(1); // no second attempt expect(seen.map((e) => e.type)).toEqual(["started", "text-delta"]); }); it("exhausts the retry budget and marks retriesExhausted", async () => { const factory = vi.fn().mockRejectedValue(apiError(500, "internal server error")); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory, maxRetries: 2, sleep: () => Promise.resolve(), // timing covered elsewhere }); await expect(collectAll(client.stream(REQUEST))).rejects.toSatisfy( (e: unknown) => e instanceof ModelError && e.kind === "retryable" && e.retriesExhausted === true, ); expect(factory).toHaveBeenCalledTimes(3); // initial attempt + 2 retries }); });