SPB Git

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%
6.3 KB · 167 lines typescript
Raw Blame History
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