/** * KHAELOR * File: src/anthropic/errors.ts * Description: Typed model error taxonomy — retryable vs fatal vs context-overflow, mapped from SDK errors (ARCHITECTURE.md §7). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { APIConnectionError, APIError, APIUserAbortError, } from "@anthropic-ai/sdk"; /** The kernel branches on this taxonomy (ADR-10). */ export type ModelErrorKind = | "retryable" // 429 / 5xx / overloaded / network — retried inside ModelClient | "context-overflow" // routed to reactive compaction — NEVER retried blindly | "auth" // fatal; actionable message; never logs the key | "invalid-request" // fatal; a KHAELOR bug — surfaced loudly | "cancelled"; // AbortSignal fired — folds into Interrupted state /** * Typed model failure. Messages are redacted before construction — * never contain API keys or authorization headers (CLAUDE.md §6). */ export class ModelError extends Error { readonly kind: ModelErrorKind; readonly retryable: boolean; readonly status: number | undefined; readonly requestId: string | undefined; /** Server-provided backoff hint (429 retry-after), milliseconds. */ readonly retryAfterMs: number | undefined; /** Set by the client when a retry budget was consumed without success. */ retriesExhausted = false; constructor( kind: ModelErrorKind, message: string, opts: { status?: number; requestId?: string; retryAfterMs?: number; cause?: unknown } = {}, ) { super(redactSecrets(message), opts.cause === undefined ? undefined : { cause: opts.cause }); this.name = "ModelError"; this.kind = kind; this.retryable = kind === "retryable"; this.status = opts.status; this.requestId = opts.requestId; this.retryAfterMs = opts.retryAfterMs; } } /** Narrowing helper. */ export function isModelError(value: unknown): value is ModelError { return value instanceof ModelError; } /** * Strip anything that could be a secret from an error message: * Anthropic API keys and authorization/x-api-key header values. */ export function redactSecrets(text: string): string { return text .replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]") .replace(/(x-api-key\s*[:=]\s*)\S+/gi, "$1[redacted]") .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, "$1[redacted]"); } /** * The API reports context-window overflow as a 400 invalid_request_error; * detect its message shapes so the kernel can compact instead of failing. * Example: "prompt is too long: 210734 tokens > 200000 maximum". */ const CONTEXT_OVERFLOW_PATTERNS: RegExp[] = [ /prompt is too long/i, /input length and `?max_tokens`? exceed context limit/i, /context (?:length|window)/i, /exceeds? the (?:maximum )?(?:context|token) (?:window|limit)/i, ]; function isContextOverflowMessage(message: string): boolean { return CONTEXT_OVERFLOW_PATTERNS.some((re) => re.test(message)); } const NETWORK_ERROR_PATTERNS: RegExp[] = [ /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EPIPE|EAI_AGAIN/, /fetch failed/i, /network/i, /socket/i, /terminated/i, ]; /** Parse a `retry-after` header value (delta-seconds or HTTP-date) into ms. */ export function parseRetryAfterMs(value: string | null | undefined): number | undefined { if (value === null || value === undefined || value === "") return undefined; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const dateMs = Date.parse(value); if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now()); return undefined; } function fromStatus(status: number, message: string, requestId?: string, retryAfterMs?: number, cause?: unknown): ModelError { const opts = { status, ...(requestId !== undefined ? { requestId } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), cause, }; if (status === 401 || status === 403) { return new ModelError( "auth", `Anthropic authentication failed (HTTP ${status}). Check ANTHROPIC_API_KEY. ${message}`, opts, ); } if (status === 429) { return new ModelError("retryable", `Anthropic rate limit (HTTP 429). ${message}`, opts); } if (status === 413 || (status === 400 && isContextOverflowMessage(message))) { return new ModelError("context-overflow", message, opts); } if (status >= 500) { // Includes 529 overloaded_error. return new ModelError("retryable", `Anthropic server error (HTTP ${status}). ${message}`, opts); } return new ModelError("invalid-request", `Anthropic rejected the request (HTTP ${status}). ${message}`, opts); } /** * Map any thrown value (SDK error classes, abort errors, network failures) * onto the typed taxonomy. Total: always returns a ModelError. */ export function classifyModelError(err: unknown): ModelError { if (err instanceof ModelError) return err; // Cancellation — SDK abort wrapper or a raw AbortError/DOMException. if ( err instanceof APIUserAbortError || (err instanceof Error && err.name === "AbortError") ) { return new ModelError("cancelled", "Request cancelled.", { cause: err }); } // Network-level failures (no HTTP status). if (err instanceof APIConnectionError) { return new ModelError("retryable", `Network error reaching the Anthropic API. ${err.message}`, { cause: err, }); } // HTTP errors from the API. if (err instanceof APIError) { const status = typeof err.status === "number" ? err.status : undefined; const requestId = err.requestID ?? undefined; const retryAfterMs = parseRetryAfterMs(err.headers?.get?.("retry-after")); if (status !== undefined) { return fromStatus(status, err.message, requestId, retryAfterMs, err); } return new ModelError("retryable", `Anthropic API error without status. ${err.message}`, { ...(requestId !== undefined ? { requestId } : {}), cause: err, }); } // Undici/fetch style network failures thrown as plain errors. if (err instanceof Error) { const text = `${err.message} ${err.cause instanceof Error ? err.cause.message : ""}`; if (NETWORK_ERROR_PATTERNS.some((re) => re.test(text))) { return new ModelError("retryable", `Network error reaching the Anthropic API. ${err.message}`, { cause: err, }); } return new ModelError("invalid-request", `Unexpected model runtime error: ${err.message}`, { cause: err, }); } return new ModelError("invalid-request", `Unexpected model runtime error: ${String(err)}`); }