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}The thirteen kinds
Section titled “The thirteen kinds”| 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.
Centralized retry predicate: isRetryable
Section titled “Centralized retry predicate: isRetryable”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:
- Caller aborts (
AbortSignal) are never retryable. - An explicit
shouldRetrydirective onProviderError(honoring the server’sx-should-retryheader) wins over default classification. - Non-retryable kinds (
quota,context,model,entitlement,content,invalid) returnfalse. - Excessive waits (
retryAfterMs > maxWaitMs, default 60s) returnfalse. Long cooldowns belong to outer fallbacks, not backoff loops. - Transient kinds and transport failures (
fetch failed, socket drops, TLS glitches) returntrue.
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}Why the status code is not enough
Section titled “Why the status code is not enough”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.
Failures with no status at all
Section titled “Failures with no status at all”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.
The failure that arrives inside a 200
Section titled “The failure that arrives inside a 200”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.
Retry-After
Section titled “Retry-After”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.
The error object
Section titled “The error object”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.