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: src/anthropic/errors.ts4 * Description: Typed model error taxonomy — retryable vs fatal vs context-overflow, mapped from SDK errors (ARCHITECTURE.md §7).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import {11 APIConnectionError,12 APIError,13 APIUserAbortError,14} from "@anthropic-ai/sdk";1516/** The kernel branches on this taxonomy (ADR-10). */17export type ModelErrorKind =18 | "retryable" // 429 / 5xx / overloaded / network — retried inside ModelClient19 | "context-overflow" // routed to reactive compaction — NEVER retried blindly20 | "auth" // fatal; actionable message; never logs the key21 | "invalid-request" // fatal; a KHAELOR bug — surfaced loudly22 | "cancelled"; // AbortSignal fired — folds into Interrupted state2324/**25 * Typed model failure. Messages are redacted before construction —26 * never contain API keys or authorization headers (CLAUDE.md §6).27 */28export class ModelError extends Error {29 readonly kind: ModelErrorKind;30 readonly retryable: boolean;31 readonly status: number | undefined;32 readonly requestId: string | undefined;33 /** Server-provided backoff hint (429 retry-after), milliseconds. */34 readonly retryAfterMs: number | undefined;35 /** Set by the client when a retry budget was consumed without success. */36 retriesExhausted = false;3738 constructor(39 kind: ModelErrorKind,40 message: string,41 opts: { status?: number; requestId?: string; retryAfterMs?: number; cause?: unknown } = {},42 ) {43 super(redactSecrets(message), opts.cause === undefined ? undefined : { cause: opts.cause });44 this.name = "ModelError";45 this.kind = kind;46 this.retryable = kind === "retryable";47 this.status = opts.status;48 this.requestId = opts.requestId;49 this.retryAfterMs = opts.retryAfterMs;50 }51}5253/** Narrowing helper. */54export function isModelError(value: unknown): value is ModelError {55 return value instanceof ModelError;56}5758/**59 * Strip anything that could be a secret from an error message:60 * Anthropic API keys and authorization/x-api-key header values.61 */62export function redactSecrets(text: string): string {63 return text64 .replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]")65 .replace(/(x-api-key\s*[:=]\s*)\S+/gi, "$1[redacted]")66 .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, "$1[redacted]");67}6869/**70 * The API reports context-window overflow as a 400 invalid_request_error;71 * detect its message shapes so the kernel can compact instead of failing.72 * Example: "prompt is too long: 210734 tokens > 200000 maximum".73 */74const CONTEXT_OVERFLOW_PATTERNS: RegExp[] = [75 /prompt is too long/i,76 /input length and `?max_tokens`? exceed context limit/i,77 /context (?:length|window)/i,78 /exceeds? the (?:maximum )?(?:context|token) (?:window|limit)/i,79];8081function isContextOverflowMessage(message: string): boolean {82 return CONTEXT_OVERFLOW_PATTERNS.some((re) => re.test(message));83}8485const NETWORK_ERROR_PATTERNS: RegExp[] = [86 /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EPIPE|EAI_AGAIN/,87 /fetch failed/i,88 /network/i,89 /socket/i,90 /terminated/i,91];9293/** Parse a `retry-after` header value (delta-seconds or HTTP-date) into ms. */94export function parseRetryAfterMs(value: string | null | undefined): number | undefined {95 if (value === null || value === undefined || value === "") return undefined;96 const seconds = Number(value);97 if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);98 const dateMs = Date.parse(value);99 if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());100 return undefined;101}102103function fromStatus(status: number, message: string, requestId?: string, retryAfterMs?: number, cause?: unknown): ModelError {104 const opts = {105 status,106 ...(requestId !== undefined ? { requestId } : {}),107 ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),108 cause,109 };110 if (status === 401 || status === 403) {111 return new ModelError(112 "auth",113 `Anthropic authentication failed (HTTP ${status}). Check ANTHROPIC_API_KEY. ${message}`,114 opts,115 );116 }117 if (status === 429) {118 return new ModelError("retryable", `Anthropic rate limit (HTTP 429). ${message}`, opts);119 }120 if (status === 413 || (status === 400 && isContextOverflowMessage(message))) {121 return new ModelError("context-overflow", message, opts);122 }123 if (status >= 500) {124 // Includes 529 overloaded_error.125 return new ModelError("retryable", `Anthropic server error (HTTP ${status}). ${message}`, opts);126 }127 return new ModelError("invalid-request", `Anthropic rejected the request (HTTP ${status}). ${message}`, opts);128}129130/**131 * Map any thrown value (SDK error classes, abort errors, network failures)132 * onto the typed taxonomy. Total: always returns a ModelError.133 */134export function classifyModelError(err: unknown): ModelError {135 if (err instanceof ModelError) return err;136137 // Cancellation — SDK abort wrapper or a raw AbortError/DOMException.138 if (139 err instanceof APIUserAbortError ||140 (err instanceof Error && err.name === "AbortError")141 ) {142 return new ModelError("cancelled", "Request cancelled.", { cause: err });143 }144145 // Network-level failures (no HTTP status).146 if (err instanceof APIConnectionError) {147 return new ModelError("retryable", `Network error reaching the Anthropic API. ${err.message}`, {148 cause: err,149 });150 }151152 // HTTP errors from the API.153 if (err instanceof APIError) {154 const status = typeof err.status === "number" ? err.status : undefined;155 const requestId = err.requestID ?? undefined;156 const retryAfterMs = parseRetryAfterMs(err.headers?.get?.("retry-after"));157 if (status !== undefined) {158 return fromStatus(status, err.message, requestId, retryAfterMs, err);159 }160 return new ModelError("retryable", `Anthropic API error without status. ${err.message}`, {161 ...(requestId !== undefined ? { requestId } : {}),162 cause: err,163 });164 }165166 // Undici/fetch style network failures thrown as plain errors.167 if (err instanceof Error) {168 const text = `${err.message} ${err.cause instanceof Error ? err.cause.message : ""}`;169 if (NETWORK_ERROR_PATTERNS.some((re) => re.test(text))) {170 return new ModelError("retryable", `Network error reaching the Anthropic API. ${err.message}`, {171 cause: err,172 });173 }174 return new ModelError("invalid-request", `Unexpected model runtime error: ${err.message}`, {175 cause: err,176 });177 }178179 return new ModelError("invalid-request", `Unexpected model runtime error: ${String(err)}`);180}181