Skip to content

Errors

An error only ever answers one question: what do I do now? So the taxonomy is organised by the fix, not by the vendor or the status code.

import { ProviderError, isTransient, isBackupEligible } from "@providerkit/core";
try {
/* ... */
} catch (raw) {
const err = ProviderError.from("anthropic", raw);
if (err.kind === "context") return compactAndRetry();
if (isBackupEligible(err.kind)) return otherModel();
if (isTransient(err.kind)) return retry(err.retryAfterMs);
surface(err); // .body has their actual words
}
Kind What fixes it
aborted Nothing. The caller pressed Stop. This is not a failure.
timeout Retry. Our deadline fired, not their answer.
network Retry. The request never reached them.
overload Retry, or fall back to another model.
rate Wait out the window, or rotate the key or model.
quota Top up, or wait for the reset. Retrying will not help.
entitlement Change the plan. A new key and a top-up both fail here.
auth Fix the credential. Every retry lands the same.
model Use a model this endpoint actually serves.
context Send less. Compact the conversation. Waiting fixes nothing.
content A safety filter caught the prompt or the answer.
invalid Fix the request. This one is our bug.
unknown Unrecognised. Surface the body and look at it.

isTransient covers timeout, network, overload and rate. isBackupEligible covers overload and rate, the two where a different model is a real fix.

Rather than checking status codes and error kinds manually across loops, isRetryable evaluates any caught error or response:

import { isRetryable } from "@providerkit/core";
if (isRetryable(err)) {
// Safe to retry on the same endpoint
}

Order of decision:

  1. Caller aborts (AbortSignal) are never retryable.
  2. An explicit shouldRetry directive on ProviderError (honoring the server’s x-should-retry header) wins over default classification.
  3. Non-retryable kinds (quota, context, model, entitlement, content, invalid) return false.
  4. Excessive waits (retryAfterMs > maxWaitMs, default 60s) return false. Long cooldowns belong to outer fallbacks, not backoff loops.
  5. Transient kinds and transport failures (fetch failed, socket drops, TLS glitches) return true.

Parsing context overflows: parseContextOverflow

Section titled “Parsing context overflows: parseContextOverflow”

When a prompt or max_tokens exceeds the model’s window, Anthropic and gateways report the exact numbers:

import { parseContextOverflow } from "@providerkit/core";
const overflow = parseContextOverflow(err);
// { inputTokens: 188059, maxTokens: 20000, contextLimit: 200000, excessTokens: 8059 }
if (overflow) {
// Dynamically lower maxTokens or compact history by the excess margin before retrying
}

Running out of credit arrives as 429 from OpenAI (insufficient_quota), 402 from some gateways, 403 from others, and 400 from Moonshot with a Chinese-language body. Retrying an empty balance is pure waste, and status alone tells you to do exactly that.

The reverse trap is worse. A 429 is usually a throttle, where waiting is the whole fix. But a context overflow also arrives as 400 (and sometimes 429), where waiting fixes nothing and the only cure is sending less.

So the classifier reads the body before the status for 4xx. The body says what is actually wrong; the status says only how the vendor chose to file it.

A dead socket carries no HTTP response. Node wraps the real reason several layers deep, so the useful code sits on cause.cause.cause. isTransportFailure walks the cause chain five levels looking for the signatures: ECONNRESET, UND_ERR_SOCKET, fetch failed, and friends.

Miss that walk and every transient network blip reads as a permanent failure, which is how a run dies on a hiccup that a single retry would have cleared.

The adapters don’t rely on the walk for a request that got no answer at all. Every fetch rejection that isn’t your own abort is network, whatever its wording. Each runtime words that failure its own way, and one wording nobody had listed used to end the run.

An SSE response commits to 200 the moment its headers go out. Everything that goes wrong after that (a throttle, an upstream outage, a prompt the backend only measured once it started) has nowhere to be but the body.

Every shape does this, in its own dialect: a {"error":…} frame on the OpenAI shape and the gateways in front of it, an error event on Anthropic, response.failed on Responses, a bare google.rpc.Status on Gemini. All four adapters read theirs and throw a classified ProviderError, the same one a pre-body failure would have produced.

This is the failure mode worth knowing about even if you never call the helper yourself, because the version that goes unread is invisible: the payload matches no branch, the loop skips it, and the turn ends as a successful, zero-token completion. Nothing to retry, nothing in the log, and a key pool that never rotates off a spent key.

import { streamError } from "@providerkit/core";
// Writing your own adapter? This is the branch to keep.
if (payload.error) throw streamError("my-gateway", payload.error);

A number in that payload counts as an HTTP status only from 400 to 599. Vendors put their own codes in the same field: Z.ai’s 1113 is an empty balance, and read as a status it landed on overload instead of quota. With no status, the error’s type stands in for one: invalid_request_error counts as a 400 and rate_limit_error as a 429. The body’s own words still come first, as they do everywhere else.

unknown is floored to overload here and nowhere else: a stream that dies after its headers is by construction a transient upstream fault, and unknown is never retried. Only an error with no status and no type it knows gets there. Everything the body does name keeps its own kind.

parseRetryAfterMs reads a server-requested wait from a Retry-After header (both the seconds and the HTTP-date form) and from Gemini’s RetryInfo body, where the delay is a string like "48s". It lands on err.retryAfterMs.

A long one is information, not just a delay: a wait measured in hours is a usage window, not a blip, and a run is usually better off failing fast with the reset time than sleeping into the same wall.

class ProviderError extends Error {
readonly provider: string;
readonly kind: ErrorKind;
readonly status?: number;
readonly code?: string;
readonly retryAfterMs?: number;
readonly body?: string; // their words, kept for the surface
get isTransient(): boolean;
get isBackupEligible(): boolean;
static from(provider: string, err: unknown): ProviderError;
}

ProviderError.from is idempotent: passing one back in returns it unchanged, so wrapping at several layers is safe.

Keep body. When a failure lands as unknown, the vendor’s own words are the only thing that tells you what to add to the classifier.