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.4 KB · 181 lines typescript
Raw Blame History
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