spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/anthropic/retry.test.ts4 * Description: Retry policy tests — exponential backoff with jitter, retry-after, retryable-only, before-first-token-only.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";11import { APIError } from "@anthropic-ai/sdk";12import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages";13import { AnthropicModelClient, backoffDelayMs } from "../../src/anthropic/client.js";14import { ModelError } from "../../src/anthropic/errors.js";15import type { ModelEvent, ModelRequest } from "../../src/anthropic/types.js";16import { asRawStream, messageStart, simpleTextStream, textBlockStart, textDelta } from "./fixtures.js";1718const REQUEST: ModelRequest = {19 model: "claude-sonnet-5",20 system: [{ name: "identity", text: "You are KHAELOR." }],21 messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],22 tools: [],23 maxOutputTokens: 64,24};2526function apiError(status: number, message: string, headers?: Record<string, string>): APIError {27 return APIError.generate(28 status,29 { error: { type: "error", message } },30 message,31 new Headers(headers ?? {}),32 );33}3435async function collectAll(iterable: AsyncIterable<ModelEvent>): Promise<ModelEvent[]> {36 const out: ModelEvent[] = [];37 for await (const event of iterable) out.push(event);38 return out;39}4041describe("backoffDelayMs", () => {42 const opts = { baseMs: 500, capMs: 8_000, random: () => 0.5 };4344 it("grows exponentially with equal jitter and respects the cap", () => {45 expect(backoffDelayMs(1, opts)).toBe(375); // 500/2 + 0.5·25046 expect(backoffDelayMs(2, opts)).toBe(750);47 expect(backoffDelayMs(3, opts)).toBe(1_500);48 expect(backoffDelayMs(10, opts)).toBe(6_000); // capped raw 8000 → 4000 + 200049 });5051 it("jitter spans [raw/2, raw]", () => {52 expect(backoffDelayMs(1, { ...opts, random: () => 0 })).toBe(250);53 expect(backoffDelayMs(1, { ...opts, random: () => 1 })).toBe(500);54 });5556 it("a server retry-after hint overrides the exponential delay, capped at 30 s", () => {57 expect(backoffDelayMs(1, { ...opts, retryAfterMs: 7_000 })).toBe(7_000);58 expect(backoffDelayMs(1, { ...opts, retryAfterMs: 120_000 })).toBe(30_000);59 });60});6162describe("AnthropicModelClient retry policy", () => {63 beforeEach(() => {64 vi.useFakeTimers();65 });66 afterEach(() => {67 vi.useRealTimers();68 });6970 it("retries retryable errors with exponential backoff before the first token (fake timers)", async () => {71 const factory = vi72 .fn()73 .mockRejectedValueOnce(apiError(529, "overloaded_error: Overloaded"))74 .mockRejectedValueOnce(apiError(500, "internal server error"))75 .mockImplementationOnce(() => Promise.resolve(asRawStream(simpleTextStream())));76 const client = new AnthropicModelClient({77 apiKey: "test-key",78 streamFactory: factory,79 random: () => 1, // deterministic: delay = full raw backoff80 });8182 const promise = collectAll(client.stream(REQUEST));83 await vi.advanceTimersByTimeAsync(0);84 expect(factory).toHaveBeenCalledTimes(1);8586 await vi.advanceTimersByTimeAsync(499); // first backoff = 500·2⁰ = 500 ms87 expect(factory).toHaveBeenCalledTimes(1);88 await vi.advanceTimersByTimeAsync(1);89 expect(factory).toHaveBeenCalledTimes(2);9091 await vi.advanceTimersByTimeAsync(999); // second backoff = 500·2¹ = 1000 ms92 expect(factory).toHaveBeenCalledTimes(2);93 await vi.advanceTimersByTimeAsync(1);94 expect(factory).toHaveBeenCalledTimes(3);9596 const events = await promise;97 expect(events.map((e) => e.type)).toEqual([98 "started",99 "text-delta",100 "text-delta",101 "text-block-completed",102 "completed",103 ]);104 });105106 it("honors a 429 retry-after hint as the backoff delay", async () => {107 const factory = vi108 .fn()109 .mockRejectedValueOnce(apiError(429, "rate limited", { "retry-after": "7" }))110 .mockImplementationOnce(() => Promise.resolve(asRawStream(simpleTextStream())));111 const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory });112113 const promise = collectAll(client.stream(REQUEST));114 await vi.advanceTimersByTimeAsync(6_999);115 expect(factory).toHaveBeenCalledTimes(1);116 await vi.advanceTimersByTimeAsync(1);117 expect(factory).toHaveBeenCalledTimes(2);118 await expect(promise).resolves.toHaveLength(5);119 });120121 it("does not retry fatal errors", async () => {122 const factory = vi.fn().mockRejectedValue(apiError(401, "invalid x-api-key"));123 const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory });124125 await expect(collectAll(client.stream(REQUEST))).rejects.toSatisfy(126 (e: unknown) => e instanceof ModelError && e.kind === "auth",127 );128 expect(factory).toHaveBeenCalledTimes(1);129 });130131 it("does not retry once events have been yielded (mid-stream failure surfaces)", async () => {132 async function* failsMidStream(): AsyncGenerator<RawMessageStreamEvent, void, undefined> {133 yield messageStart();134 yield textBlockStart(0);135 yield textDelta(0, "partial");136 throw apiError(500, "stream broke");137 }138 const factory = vi.fn().mockImplementation(() => Promise.resolve(failsMidStream()));139 const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory });140141 const seen: ModelEvent[] = [];142 await expect(143 (async () => {144 for await (const event of client.stream(REQUEST)) seen.push(event);145 })(),146 ).rejects.toSatisfy((e: unknown) => e instanceof ModelError && e.kind === "retryable");147 expect(factory).toHaveBeenCalledTimes(1); // no second attempt148 expect(seen.map((e) => e.type)).toEqual(["started", "text-delta"]);149 });150151 it("exhausts the retry budget and marks retriesExhausted", async () => {152 const factory = vi.fn().mockRejectedValue(apiError(500, "internal server error"));153 const client = new AnthropicModelClient({154 apiKey: "test-key",155 streamFactory: factory,156 maxRetries: 2,157 sleep: () => Promise.resolve(), // timing covered elsewhere158 });159160 await expect(collectAll(client.stream(REQUEST))).rejects.toSatisfy(161 (e: unknown) =>162 e instanceof ModelError && e.kind === "retryable" && e.retriesExhausted === true,163 );164 expect(factory).toHaveBeenCalledTimes(3); // initial attempt + 2 retries165 });166});167