Normalize OpenAI, Anthropic and Gemini API errors into one shape: category, retryable flag and
Retry-Afterdelay. Zero dependencies.
Security posture is tracked in docs/security-posture.md, including CodeQL, OpenSSF Scorecard, Dependabot and branch rules.
Every LLM provider fails differently. OpenAI nests { error: { type, code, param } }, Anthropic wraps { type: "error", error: { type } }, Gemini speaks Google RPC status strings, and each puts retry hints in a different place. Generic HTTP failures add their own wrinkles with status-only errors and Retry-After headers. llm-errors collapses all of that into a single, predictable object so your retry and error-handling code stays provider-agnostic.
import { normalizeError, getRetryDelayMs } from 'llm-errors';
try {
await client.chat.completions.create(params);
} catch (err) {
const e = normalizeError(err);
// -> { provider: 'openai', category: 'rate_limit', retryable: true, retryAfterMs: 2000, ... }
if (e.category === 'context_length_exceeded') trimHistory();
else if (e.retryable) await sleep(getRetryDelayMs(e, attempt));
else throw err;
}- One
switch, not three. Arate_limitis arate_limitwhether it came from OpenAI'scode, Anthropic'stype, or Gemini'sRESOURCE_EXHAUSTED. - Correct retry decisions.
insufficient_quotaandcontext_length_exceededlook like other 4xx/429s but are not worth retrying.llm-errorsseparates them out. - Honours
Retry-Aftersafely. Reads theRetry-Afterheader (seconds or HTTP date),retry-after-ms, and Google'sRetryInfo.retryDelayfor retryable errors — then falls back to exponential backoff with jitter when none is given. - Never throws. Feed it an SDK error, a raw
fetchresponse, plain JSON,null, or a string — it always returns aNormalizedError. - Transport errors too. Connection timeouts, resets and DNS failures (
ETIMEDOUT,ECONNRESET,AbortError, …) have no HTTP status, yet they are retryable —llm-errorsclassifies them astimeout/server_errorinstead of dropping them. - Zero dependencies, ESM + CJS, fully typed.
npm install llm-errorsThe npm package includes a public fixture corpus under
fixtures/. It pairs raw SDK-like, fetch-like and
transport-level provider errors with the normalized output expected from
normalizeError.
These fixtures are useful for downstream regression tests when you want to verify provider-portable retry and error handling without importing OpenAI, Anthropic or Gemini SDKs.
Classifies any value into:
interface NormalizedError {
provider: 'openai' | 'anthropic' | 'gemini' | 'unknown';
category: ErrorCategory;
message: string;
status?: number; // HTTP status, when available
code?: string; // provider-specific code / type
retryable: boolean;
retryAfterMs?: number; // provider-supplied delay for retryable errors, if any
raw: unknown; // the original input
}The provider is auto-detected from SDK errors, parsed fetch envelopes and direct provider error bodies. Pass { provider } to force it when you already know which client threw or the shape is ambiguous:
normalizeError(err, { provider: 'anthropic' });Unknown providers still get safe status-based behavior. For example, a plain
{ status: 503, headers: { "Retry-After": "4" } } normalizes to
provider: "unknown", category: "overloaded", retryable: true and
retryAfterMs: 4000. A non-retryable unknown status ignores the same header.
| Category | Retryable | Typical cause |
|---|---|---|
authentication |
no | Missing / invalid API key (401) |
permission |
no | Key valid but not allowed (403) |
rate_limit |
yes | Too many requests (429) |
insufficient_quota |
no | Billing / credits exhausted (429) |
context_length_exceeded |
no | Prompt + completion over the context window |
request_too_large |
no | Payload too large (413) |
invalid_request |
no | Malformed request (400 / 422) |
not_found |
no | Unknown model or resource (404) |
content_filter |
no | Blocked by a safety policy |
timeout |
yes | Request / upstream timeout (504) |
server_error |
yes | Upstream failure (500) |
overloaded |
yes | Provider temporarily overloaded (503 / 529) |
unknown |
no | Could not be classified |
Only rate_limit, server_error, overloaded and timeout are retryable.
unknown is deliberately not retryable, so unrecognized shapes fail closed
instead of causing accidental retry storms.
Shorthand for normalizeError(error).retryable.
Returns the delay to wait before the next attempt. Non-retryable errors return 0. If the provider supplied a valid retryAfterMs, that wins. Otherwise it computes exponential backoff baseMs * 2 ** attempt, capped at maxMs, with full jitter by default.
getRetryDelayMs(e, attempt, { baseMs: 500, maxMs: 60_000, jitter: 'full' });The low-level helpers, exported for advanced use.
import { normalizeError, getRetryDelayMs } from 'llm-errors';
async function withRetries<T>(call: () => Promise<T>, max = 5): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await call();
} catch (err) {
const e = normalizeError(err);
if (!e.retryable || attempt >= max) throw err;
await new Promise((r) => setTimeout(r, getRetryDelayMs(e, attempt)));
}
}
}tool-schema— convert a JSON Schema into OpenAI / Anthropic / Gemini / MCP tool schemas.llm-messages— convert conversations and responses between providers.
MIT © Sebastian Legarraga