/** * Provider-agnostic error classification helper (OpenAI first, Anthropic shape supported). * STATUS: LIVE_VERIFIED offline for the OpenAI shapes captured on 2026-09-18 (self-test: `node classify_error.ts`). * * Handles OpenAI's three body shapes: * {"error":{"message","type","param","code"}} | {"error":""} (admin 403) | "" (edge 404, no body) * and Anthropic's {"type":"error","error":{"type","message"}} (request id header `request-id`). */ export type Category = "auth" | "permission" | "not_found" | "bad_request" | "rate_limit" | "quota_billing" | "safety" | "server" | "network" | "unknown"; export interface ErrorClass { provider: string; httpStatus: number | null; type: string | null; code: string | null; param: string | null; message: string; category: Category; retryable: boolean; retryAfterSec: number | null; requestId: string | null; } const NON_RETRYABLE_429 = new Set(["insufficient_quota", "credit_balance_exhausted", "organization_spend_limit_exceeded", "project_spend_limit_exceeded", "organization_usage_limit_exceeded", "billing_hard_limit_reached"]); const SAFETY = new Set(["misalignment_policy_violation", "cyber_policy", "moderation_blocked", "content_policy_violation", "invalid_prompt"]); const AUTH = new Set(["invalid_api_key", "mismatched_organization", "invalid_project"]); type HeadersLike = Record | { get(name: string): string | null } | null | undefined; function hdr(h: HeadersLike, name: string): string | null { if (!h) return null; if (typeof (h as any).get === "function") return (h as any).get(name); for (const [k, v] of Object.entries(h as Record)) if (k.toLowerCase() === name && v) return Array.isArray(v) ? v[0] : v; return null; } export function classify(provider: string, status: number | null, body: unknown, headers?: HeadersLike): ErrorClass { let err: any = null; if (body && typeof body === "object") err = (body as any).error ?? body; else if (typeof body === "string" && body) err = body; let type: string | null = null, code: string | null = null, param: string | null = null, message = ""; if (err && typeof err === "object") { type = err.type ?? null; code = err.code ?? null; param = err.param ?? null; message = String(err.message ?? ""); } else if (typeof err === "string") message = err; const ms = hdr(headers, "retry-after-ms"); const ra = hdr(headers, "retry-after"); const retryAfterSec = ms && !Number.isNaN(Number(ms)) ? Number(ms) / 1000 : ra && !Number.isNaN(Number(ra)) ? Number(ra) : null; const requestId = hdr(headers, "x-request-id") ?? hdr(headers, "request-id"); let category: Category; let retryable: boolean; if (status === null) { category = "network"; retryable = true; } else if (status === 401 || (code && AUTH.has(code)) || type === "authentication_error") { category = "auth"; retryable = false; } else if (status === 403 || type === "permission_error" || code === "insufficient_permissions" || code === "ip_not_authorized") { category = code && SAFETY.has(code) ? "safety" : "permission"; retryable = false; } else if (status === 404 || type === "not_found_error" || code === "model_not_found") { category = "not_found"; retryable = false; } else if (status === 429) { if ((code && NON_RETRYABLE_429.has(code)) || type === "insufficient_quota") { category = "quota_billing"; retryable = false; } else { category = "rate_limit"; retryable = true; } } else if (status >= 500 || status === 408 || status === 409 || status === 425) { category = "server"; retryable = true; } else if ((code && SAFETY.has(code)) || type === "image_generation_user_error") { category = "safety"; retryable = false; } else if (status >= 400) { category = "bad_request"; retryable = status === 422; } else { category = "unknown"; retryable = false; } if (message.includes("Missing scopes")) { category = "permission"; retryable = false; } return { provider, httpStatus: status, type, code, param, message, category, retryable, retryAfterSec, requestId }; } /** Delay (seconds) before retry #attempt (1-based): server hint first, else exponential backoff with jitter (0.5 s .. 8 s). */ export function backoff(e: ErrorClass, attempt: number, base = 0.5, cap = 8): number { if (e.retryAfterSec !== null) return e.retryAfterSec + Math.random() * 0.25; return Math.min(cap, base * 2 ** (attempt - 1)) * (1 + (Math.random() - 0.5) / 2); } // ---- self-test on captured OpenAI shapes (run: node classify_error.ts) ---- if (import.meta.url === `file://${process.argv[1]}`) { const cases: Array<[number | null, unknown, Category, boolean]> = [ [401, { error: { message: "Incorrect API key provided: sk-inv***", type: "invalid_request_error", param: null, code: "invalid_api_key" } }, "auth", false], [403, { error: "You have insufficient permissions for this operation. Missing scopes: api.management.read." }, "permission", false], [404, { error: { message: "The model `x` does not exist", type: "invalid_request_error", param: null, code: "model_not_found" } }, "not_found", false], [404, "", "not_found", false], [400, { error: { message: "Missing required parameter: 'model'.", type: "invalid_request_error", param: "model", code: "missing_required_parameter" } }, "bad_request", false], [429, { error: { message: "Rate limit reached", type: "rate_limit_error", param: null, code: "rate_limit_exceeded" } }, "rate_limit", true], [429, { error: { message: "You exceeded your current quota", type: "insufficient_quota", param: null, code: "insufficient_quota" } }, "quota_billing", false], [503, { error: { message: "overloaded", type: "service_unavailable_error", param: null, code: "server_is_overloaded" } }, "server", true], [403, { error: { message: "blocked", type: "invalid_request_error", param: null, code: "misalignment_policy_violation" } }, "safety", false], [529, { type: "error", error: { type: "overloaded_error", message: "Overloaded" } }, "server", true], [null, null, "network", true], ]; for (const [st, body, cat, retry] of cases) { const c = classify(st === 529 ? "anthropic" : "openai", st, body, { "retry-after": "2" }); if (c.category !== cat || c.retryable !== retry) throw new Error(`FAIL ${st} ${JSON.stringify(body)} -> ${c.category}/${c.retryable}`); } console.log(`OK — ${cases.length} classification cases passed`); }