/** * KHAELOR * File: tests/anthropic/errors.test.ts * Description: Error taxonomy tests — SDK error mapping, context-overflow detection, retry-after parsing, secret redaction. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { APIConnectionError, APIError, APIUserAbortError } from "@anthropic-ai/sdk"; import { ModelError, classifyModelError, parseRetryAfterMs, redactSecrets, } from "../../src/anthropic/errors.js"; function apiError(status: number, message: string, headers?: Record): APIError { return APIError.generate( status, { error: { type: "error", message } }, message, new Headers(headers ?? {}), ); } describe("classifyModelError", () => { it("maps 429 to retryable and extracts retry-after (delta-seconds)", () => { const err = classifyModelError(apiError(429, "rate limited", { "retry-after": "7" })); expect(err.kind).toBe("retryable"); expect(err.retryable).toBe(true); expect(err.status).toBe(429); expect(err.retryAfterMs).toBe(7_000); }); it("maps 500 and 529 (overloaded) to retryable", () => { expect(classifyModelError(apiError(500, "internal server error")).kind).toBe("retryable"); expect(classifyModelError(apiError(529, "overloaded_error: Overloaded")).kind).toBe("retryable"); }); it("maps network failures to retryable", () => { expect( classifyModelError(new APIConnectionError({ message: "Connection error." })).kind, ).toBe("retryable"); expect(classifyModelError(new TypeError("fetch failed")).kind).toBe("retryable"); }); it("maps 401/403 to fatal auth", () => { const err = classifyModelError(apiError(401, "invalid x-api-key")); expect(err.kind).toBe("auth"); expect(err.retryable).toBe(false); expect(classifyModelError(apiError(403, "forbidden")).kind).toBe("auth"); }); it("maps a generic 400 to fatal invalid-request", () => { const err = classifyModelError(apiError(400, "tools.0.input_schema: field required")); expect(err.kind).toBe("invalid-request"); expect(err.retryable).toBe(false); }); it("detects the API's context-length error shape as context-overflow", () => { const err = classifyModelError( apiError(400, "prompt is too long: 210734 tokens > 200000 maximum"), ); expect(err.kind).toBe("context-overflow"); expect(err.retryable).toBe(false); expect( classifyModelError( apiError(400, "input length and `max_tokens` exceed context limit: 199999 + 8192 > 200000"), ).kind, ).toBe("context-overflow"); expect(classifyModelError(apiError(413, "request too large")).kind).toBe("context-overflow"); }); it("maps abort errors to cancelled", () => { expect(classifyModelError(new APIUserAbortError()).kind).toBe("cancelled"); const abortError = new Error("The operation was aborted"); abortError.name = "AbortError"; expect(classifyModelError(abortError).kind).toBe("cancelled"); }); it("passes through an existing ModelError unchanged", () => { const original = new ModelError("context-overflow", "too long"); expect(classifyModelError(original)).toBe(original); }); it("maps unknown errors to fatal invalid-request (a KHAELOR bug, surfaced loudly)", () => { const err = classifyModelError(new Error("undefined is not a function")); expect(err.kind).toBe("invalid-request"); expect(err.retryable).toBe(false); }); }); describe("secret hygiene", () => { it("redacts API keys and auth header values from messages", () => { const err = new ModelError( "auth", "bad key sk-ant-api03-abc_DEF-123 with authorization: Bearer sk-ant-zzz and x-api-key: whatever123", ); expect(err.message).not.toContain("sk-ant-api03"); expect(err.message).not.toContain("whatever123"); expect(err.message).toContain("[redacted]"); }); it("redactSecrets leaves normal text intact", () => { expect(redactSecrets("prompt is too long: 210734 tokens")).toBe( "prompt is too long: 210734 tokens", ); }); }); describe("parseRetryAfterMs", () => { it("parses delta-seconds", () => { expect(parseRetryAfterMs("2")).toBe(2_000); expect(parseRetryAfterMs("0")).toBe(0); }); it("parses an HTTP-date in the future", () => { const value = new Date(Date.now() + 5_000).toUTCString(); const ms = parseRetryAfterMs(value); expect(ms).toBeGreaterThan(0); expect(ms).toBeLessThanOrEqual(5_000); }); it("returns undefined for absent or garbage values", () => { expect(parseRetryAfterMs(null)).toBeUndefined(); expect(parseRetryAfterMs(undefined)).toBeUndefined(); expect(parseRetryAfterMs("")).toBeUndefined(); expect(parseRetryAfterMs("soon")).toBeUndefined(); }); });