VernLLMVernLLM
Core Features

Error Handling

LLMError types, what triggers each, and what gets retried

When a raw API error or unknown thrown error is normalized, the original error is preserved on err.cause, in case the normalized type/status/issues aren't enough to diagnose the failure. Existing LLMError instances are returned unchanged, so internally-created errors such as timeout, parse, and validation errors may not have a cause.

error-handling-example.ts
import { isLLMError } from 'vern-llm';

try {
  const result = await llm.call({ systemPrompt, userContent, schema });
} catch (err) {
  if (isLLMError(err)) {
    switch (err.type) {
      case 'validation':
        console.log(err.issues); // the schema's error object, or the tool contract issues
        break;
      case 'invalid_params':
        console.log(err.message); // the call site's own input was malformed
        break;
      case 'api':
        console.log(err.status); // HTTP status, if the provider returned one
        console.log(err.cause); // the original error thrown by the provider client
        console.log(err.retryAfterMs); // Retry-After from the last attempt, if the provider sent one
        console.log(err.code); // more specific machine readable error code, when available
        break;
      case 'circuit_open':
        // provider is down, back off and do not retry immediately
        break;
    }
  }
  throw err;
}

Error types

timeout

A single attempt exceeded timeoutMs, or a stream went quiet past chunkIdleTimeoutMs. Retried like any other transient failure.

api

The provider returned an HTTP error with a status code. Retried unless the status is in nonRetryableStatus (default 400, 401, 403, 404, 422).

network

The request never reached the provider at all: DNS resolution failing, the connection being refused, or the connection resetting mid-request. Retried like any other transient failure.

parse

A response body, SSE frame, or tool call argument string was not valid JSON. Never retried because a malformed response will not fix itself on retry.

validation

The model's or provider's own response failed a local contract check: JSON parsed successfully but failed schema.safeParse, or a tool contract problem such as unknown_tool, duplicate_tool_call_id, tool_choice_none_violated, or unexpected_tool_calls. Never retried, for the same reason as parse. Carries .issues with the validator's error object or the detected tool contract problems. Note that usage tracking may have already reported the provider response before this validation failure occurs.

invalid_params

The caller's own call site input was malformed, caught before any request was built or sent: an empty or duplicate tools array, a toolChoice naming a tool that isn't in tools, a schema combined with jsonMode: false, invalid conversation history, an unsupported image mimeType, or a capability the current adapter/client/model doesn't support (see unsupported_capability below). Deterministic on the caller's own input and never touches the network, so it is never retried.

rate_limited

VernLLM's own local rateLimit queue rejected the call before it reached the provider: the queue was full, the wait exceeded maxQueueMs, or the call could never fit the configured capacity. See Rate Limiting. A provider's own HTTP 429 is type: 'api' with code: 'provider_rate_limited', not this type. Whether rate_limited itself is retried depends on code, see below.

quota_exceeded

The usage reservation failed before the request was sent. This happens when reserveUsage rejects and the original error is preserved on err.cause. It's retried like most other types, but excluded from circuit breaker accounting: a caller or account level limit says nothing about whether the provider itself is healthy. See Circuit Breaker.

circuit_open

The circuit breaker is open because the provider has failed too many times in a row. Fails immediately without hitting the provider.

fallback_exhausted

Every configured fallback target failed. Only ever set on FallbackExhaustedError, see below.

aborted

The caller's AbortSignal fired. This represents intentional cancellation by the caller, not a timeout. See Cancellation.

unknown

Anything that does not fit the above categories. Retried by default.

Error codes

LLMError.code provides a more specific machine readable error code when LLMError.type alone does not describe the failure precisely. type and code are independent: code is not owned by a single type, so the same code can in principle appear under a different type as VernLLM learns to detect more.

The currently defined tool contract codes are:

CodeError typeMeaning
unknown_toolvalidationThe model requested a tool that was not included in the call's tools array.
duplicate_tool_call_idvalidationThe model returned more than one tool call with the same call ID.
tool_choice_none_violatedvalidationThe provider returned tool_calls despite toolChoice: 'none'. See Tool Calling.
unexpected_tool_callsvalidationThe provider returned tool_calls when no tools were sent with the call at all.

The currently defined caller input codes are:

CodeError typeMeaning
unsupported_capabilityinvalid_paramsThe caller asked for something this specific adapter, client, or model can't do: stream: true against a client with no streaming method, tools with jsonSchema on a model outside nativeStructuredOutputModels, a model not covered by toolUseSupportedModels, or toolChoice: 'none' against a provider with no equivalent.
duplicate_tool_namesinvalid_paramsThe call's tools array had more than one entry sharing a name.
unknown_tool_choiceinvalid_paramstoolChoice named a tool that wasn't in the call's own tools array.
duplicate_tool_result_idsinvalid_paramsA history "tool" turn's toolResults had more than one entry for the same toolCallId.
unknown_tool_result_idsinvalid_paramsA history "tool" turn's toolResults referenced a toolCallId the preceding assistant turn never requested.
missing_tool_resultsinvalid_paramsA history "tool" turn was missing a result for a toolCallId the preceding assistant turn did request.

Everything else under invalid_params stays uncoded: the thrown message already names the exact problem, and a code only earns its place when a caller would realistically branch on it. These six are exactly the ones whose message already embeds structured data a caller might want directly instead of re-parsing it out of prose: a joined list of names/ids for the five history- and tools-shape checks, or the specific missing capability for unsupported_capability; see issues below for the typed payload each one carries.

The currently defined rate-limiting codes are:

CodeError typeMeaning
rate_limit_queue_fullrate_limitedA rateLimit call was rejected locally because the queue was already at maxQueueSize.
rate_limit_queue_timeoutrate_limitedA rateLimit call's queue wait exceeded maxQueueMs.
rate_limit_capacity_exceededrate_limitedThe call's estimated tokens exceed the configured tokensPerMinute capacity outright; can never succeed no matter how long the caller waits.
retry_budget_exhaustedrate_limitedA retryBudget was configured and the target's recent retry ratio reached it; further retries against that target are refused.
provider_rate_limitedapiThe provider itself returned an HTTP 429.

See Rate Limiting for the full rateLimit option and what triggers each of these. See Retry Budget for retry_budget_exhausted specifically.

The currently defined HTTP status codes are:

CodeError typeMeaning
authenticationapiThe provider returned HTTP 401: the request itself was not authenticated.
authorizationapiThe provider returned HTTP 403: the request was authenticated but not permitted.
not_foundapiThe provider returned HTTP 404, most commonly meaning the requested model doesn't exist as named.
payload_too_largeapiThe provider returned HTTP 413: the request body exceeded the provider's size limit.
server_errorapiThe provider returned a 5xx: an internal failure on the provider's side.
empty_responseapiThe provider returned a response with no content and no tool calls at all.

These are derived purely from the HTTP status code, so they apply the same way regardless of which adapter or client raised the error.

The currently defined connectivity code is:

CodeError typeMeaning
connection_failednetworkThe request never reached the provider: a transport-level failure such as DNS resolution failing, the connection being refused, or the connection resetting mid-request. Detected via known libuv error codes or fetch's own transport-failure wording, not just the absence of a status.

The currently defined fallback code is:

CodeError typeMeaning
fallback_exhaustedfallback_exhaustedThe primary target and every attempted fallback target failed. Only ever set on FallbackExhaustedError, see below.

See Provider Fallback for the full fallback option and what makes fallbackOn move to the next target versus stop.

Soft failure detection

A response can parse cleanly and pass schema validation without actually being a good answer. A model that returns a placeholder, an empty-but-technically-present string, or a low-confidence refusal looks like a success to retries and the circuit breaker, since nothing threw. The detectSoftFailure option runs once per attempt, right after a response is shaped, and can turn that result into a real failure before it reaches the caller:

soft-failure-setup.ts
const llm = new VernLLM({
  client: fromOpenAI(openai),
  model: 'gpt-4o',
  detectSoftFailure: (result, meta) => {
    // meta: { requestId, model, providerName, isFallback, attempt }
    if (typeof result === 'string' && result.trim() === 'N/A') {
      return 'soft_failure_detected';
    }
    return undefined;
  },
});

Returning undefined leaves the result as a success. Returning an LLMErrorCode throws LLMError('api', code), using soft_failure_detected as a reasonable default when no more specific code applies.

The thrown type is api, not validation. validation errors are never retried and never count toward the circuit breaker, which would make a soft failure invisible to both no matter what code was returned. api lets the returned code's own retry and breaker behavior apply normally, the same way the built-in empty-response check below already works.

A soft failure flows through the exact same retry and circuit breaker paths a real failure would: with maxRetries configured it can still succeed on a later attempt, and with circuitBreaker configured, repeated soft failures count toward the same threshold repeated real failures would. See Retries and Circuit Breaker.

A throwing hook is caught, logged, and treated the same as returning undefined, so a broken hook doesn't fail every call it's attached to.

For stream: true calls, detectSoftFailure runs at the same point, once the stream has fully accumulated, and a soft failure rejects finalResult the same as any other post-stream failure.

A streaming soft failure counts toward the circuit breaker too, the same as a non-streaming one, gated by the same countsTowardBreaker policy the non-streaming path already applies (see retryable below). A code excluded from breaker accounting, such as quota_exceeded, still won't count even when returned from detectSoftFailure. Even though the streaming attempt has already returned successfully from VernLLM's own retry loop by the time the final result is shaped, a finalize-time failure that does count is still recorded against the breaker directly.

fallback targets each accept their own detectSoftFailure. A target that leaves it unset inherits the parent instance's hook, unlike circuitBreaker and rateLimit, which are always independent per target and never inherited.

VernLLM already fails a call outright when a response arrives with no content and no tool calls at all, regardless of whether detectSoftFailure is configured. detectSoftFailure is for judgment calls beyond that baseline: a specific placeholder string, a truncated JSON shape, or anything else that's a failure for your application but not for VernLLM in general.

The currently defined soft failure code is:

CodeError typeMeaning
soft_failure_detectedapiThe default code thrown when a detectSoftFailure hook flags an otherwise successful result as a failure without returning a more specific code of its own.
error-code.ts
try {
  await llm.call({
    userContent: 'Use the available tools.',
    tools: [weatherTool],
  });
} catch (error) {
  if (isLLMError(error) && error.code === 'unknown_tool') {
    console.log('The model requested a tool that was not offered.');
  }
}

code is optional. Errors without a more specific machine readable code leave code undefined.

issues

Three fields split one failure three ways: message is prose for a human, code is a stable string a program can === against, and issues is the structured data behind that specific failure, for a program to act on without re-parsing it out of message. Two calls can throw the same code with completely different specifics (which tool names collided, which history index was bad); issues is where those per-instance specifics live.

LLMErrorIssuesByCode maps each code that carries structured issues to that payload's exact shape:

issues-by-code.ts
interface LLMErrorIssuesByCode {
  unknown_tool: ToolIssue[];
  duplicate_tool_call_id: ToolIssue[];
  duplicate_tool_names: { names: string[] };
  unknown_tool_choice: { requested: string; available: string[] };
  duplicate_tool_result_ids: { historyIndex: number; ids: string[] };
  unknown_tool_result_ids: { historyIndex: number; ids: string[] };
  missing_tool_results: { historyIndex: number; ids: string[] };
  unsupported_capability: { capability: string };
}

Not every code appears there. Most invalid_params failures are a single deterministic fact the message already states in full (an empty tools array, toolChoice set without tools, a schema combined with jsonMode: false), so a typed issues entry for them would only duplicate the message into a field, the same near-duplicate-code problem code itself avoids by staying uncoded there. The codes that do appear are exactly the ones whose message already string-joins a list.

code stays the only discriminator; hasIssues just gives that existing check a typed return instead of a manual cast:

has-issues-example.ts
import { isLLMError, hasIssues } from 'vern-llm';

try {
  await llm.call({ userContent: 'hi', tools: [weatherTool, weatherTool] });
} catch (err) {
  if (isLLMError(err) && hasIssues(err, 'duplicate_tool_names')) {
    console.log(err.issues.names); // string[], fully typed, no cast
  }
}

Schema-validation failures (type: 'validation', no code) are the one deliberate exception: issues there is the caller's own Zod-compatible validator's error object, a shape VernLLM can't know in advance since it accepts any compatible validator. That case stays unknown on LLMError.issues rather than being forced into LLMErrorIssuesByCode.

Tool contract issues

A single model response can contain multiple tool contract problems. When this happens, LLMError.issues contains the complete set of detected issues, while the top level code identifies the primary contract error.

Each issue identifies the affected tool, tool call ID, and machine readable error code:

tool-issues.ts
interface ToolIssue {
  name: string;
  toolCallId: string;
  code: 'unknown_tool' | 'duplicate_tool_call_id';
  detail?: unknown;
}

FallbackExhaustedError

When fallback is configured and every target fails, call() throws FallbackExhaustedError instead of the last target's raw error. It extends LLMError, so isLLMError and any instanceof LLMError check still passes, and it carries attempts, a snapshot of every target's own error, in the order they were tried:

fallback-exhausted-error.ts
class FallbackExhaustedError extends LLMError {
  readonly attempts: FallbackAttempt[];
}

interface FallbackAttempt {
  index: number; // -1 for the primary
  provider: string;
  model: string;
  error: LLMErrorSnapshot;
  request?: LLMRequestSnapshot;
}

FallbackExhaustedError.type is always 'fallback_exhausted', its own identity, rather than inheriting the last attempted target's own type: every target failing is a meaningfully different event from any single target's own failure. status and retryAfterMs still inherit from the last attempt, and retryable on this class defers to the last attempt's own retryable rather than anything about the fallback_exhausted type itself, since the type alone carries no retry signal.

A lone target with no fallback configured, or a chain where fallbackOn returns 'stop' on the very first failure, throws the plain, unwrapped LLMError instead, exactly as it did before fallback existed. FallbackExhaustedError only appears once more than one target was actually tried. See When every target fails.

For stream: true, this applies to opening the stream, not to what happens after. A failure before the first chunk arrives goes through the same fallback chain as a non-streaming call, so call() itself can reject with FallbackExhaustedError once every target's stream fails to open. Once a target's stream has opened and call() has returned { chunks, finalResult }, a failure partway through never falls over: finalResult rejects with the normalized LLMError from whichever target was streaming, and fallback is not attempted. See Streaming: open failures only for why.

retryable

LLMError.retryable is computed purely from type/code, independent of any specific call's nonRetryableStatus list or whether its signal has since aborted:

retryable-example.ts
if (isLLMError(err) && !err.retryable) {
  // safe to give up immediately, without inspecting type/code yourself
}

It is false when:

  • type is 'parse', 'validation', 'invalid_params', or 'aborted'
  • code is one of the tool contract codes (unknown_tool, duplicate_tool_call_id, tool_choice_none_violated, unexpected_tool_calls)
  • code is one of the local rate limit codes (rate_limit_queue_full, rate_limit_queue_timeout, rate_limit_capacity_exceeded, retry_budget_exhausted)

retryable and LLMError.countsTowardBreaker answer different questions and can disagree. quota_exceeded is retryable (true) but excluded from circuit breaker accounting, since a caller or account level limit says nothing about whether the provider itself is healthy. See Circuit Breaker for the full list of what's excluded from breaker accounting specifically.

FallbackExhaustedError overrides this to defer to the last attempted target's own retryable, since type: 'fallback_exhausted' by itself says nothing about whether retrying could help.

What gets retried

shouldRetry skips a retry when any of these are true:

  • The signal has already aborted, or error.type is 'aborted'
  • error.type is 'parse', 'validation', or 'invalid_params' because these are deterministic input or response processing failures rather than transient provider faults
  • error.code is 'unknown_tool', 'duplicate_tool_call_id', 'tool_choice_none_violated', or 'unexpected_tool_calls' because these are deterministic tool contract failures
  • error.code is 'rate_limit_queue_full' or 'rate_limit_queue_timeout' because the wait already happened and retrying would only requeue behind the same limit with nothing changed, or 'rate_limit_capacity_exceeded' because the call could never fit the configured tokensPerMinute capacity in the first place, so no amount of retrying changes that. See Interaction with retries.
  • error.code is 'retry_budget_exhausted' because a configured retryBudget decided this target has already had too many retries recently; further retrying against it is refused until the trailing window's ratio drops. See Retry Budget.
  • error.status is in nonRetryableStatus (default [400, 401, 403, 404, 422])

Everything else, including timeouts, 5xx errors, network failures, and unknown errors, is retried up to maxRetries. If the failed attempt's error carries a Retry-After header (delta seconds or HTTP date form), that value is honored for the wait, capped at the same maximum delay as backoff. Otherwise the wait falls back to exponential backoff with jitter.

Tool contract errors are not retried

unknown_tool, duplicate_tool_call_id, tool_choice_none_violated, and unexpected_tool_calls are deliberately non retryable. See Tool Calling for the canonical contract workflow and handling guidance. These errors therefore fail immediately instead of consuming the remaining maxRetries.

Tool contract errors also do not count toward the circuit breaker. They describe a model or provider response problem rather than provider availability or transport health, so unknown_tool, duplicate_tool_call_id, tool_choice_none_violated, and unexpected_tool_calls cannot cause a healthy provider's circuit to open. The same is true of invalid_params errors: they never reach the provider at all, so they say nothing about the provider's health either.

Exhausting all retries also records a circuit breaker failure if one is configured and throws the final normalized LLMError. Tool contract errors and invalid_params errors are excluded from both retry handling and circuit breaker failure accounting.

For raw API or unknown thrown errors, the underlying error is preserved on err.cause, so it is still available even though the top level message is a generic 'LLM request failed'. Existing LLMError instances are not wrapped again, so some error types such as timeout, parse, and validation may not have a cause.

kind on events versus type on errors

VernLLM's event stream uses a kind discriminant, not type, and kind: 'rate_limited' shares a string with LLMErrorType: 'rate_limited' without meaning the same thing. A rate_limited event fires whenever a call sat in the local rate limiter's queue before clearing, success included: that's the entire point of the event, reporting that rate limiting affected timing even when nothing failed. A rate_limited error only exists when a call did not clear: the queue timed out, the queue was full, or the request could never fit at all. Reading kind: 'rate_limited' as a failure count would be a real, plausible misreading, since most events with that kind correspond to calls that went on to succeed.

Debugging a failed call

When debug: true is set, a failure is also logged through the injected logger's debug method, independent of catching the thrown LLMError yourself:

debug-logging-setup.ts
const llm = new VernLLM({ client, model, debug: true });
debug-log-output
[vern:<requestId>] error:
<the provider's error message or response body>

This is useful when a failure happens somewhere you aren't directly awaiting the call() (for example, inside cachedCall's coalesced callers), since the log fires regardless of which caller ends up seeing the thrown error.

The logged description is built defensively: it prefers the provider's raw error body, JSON stringified when possible, then falls back to .message, then to a plain string conversion of the thrown value. A thrown value that cannot be serialized or stringified at all still produces a safe placeholder instead of throwing a second error while trying to log the first one.

err.cause itself isn't guaranteed to be safe to JSON.stringify on its own, since some SDKs throw errors with circular references. JSON.stringify(err) leaves cause out of its own serialization for that reason, along with the structured type/code/status/issues fields it does include. issues gets its own check: for a schema validation failure it is the caller's own validator's error object, a shape VernLLM cannot know in advance. A circular issues value (or one nested inside attempts, which carries its own issues per recorded failure) is replaced with a short marker string in the serialized output rather than dropped silently or left to throw. It also adds message and retryable, since a plain Error subclass without this wouldn't show either: message is non-enumerable on Error, and retryable is a getter, not an own property. err.attempts works the same way and never carries cause.

Request snapshots

Each recorded attempt carries both sides of the story: attempt.error is what came back, and attempt.request is what was sent. attempt.request is an LLMRequestSnapshot, holding the provider, the model, the request body as actually sent for that attempt, and the wall clock time the attempt started:

request-snapshot-shape.ts
interface LLMRequestSnapshot {
  provider: string;
  model: string;
  body: unknown;
  headers?: Record<string, string>;
  startedAt: number;
}

Auth headers are always removed before a snapshot is built, so headers never includes an API key or bearer token. Use err.attempts[i].request alongside err.attempts[i].error to see the exact payload that produced a given failure:

request-snapshot-usage.ts
if (isLLMError(err)) {
  for (const attempt of err.attempts ?? []) {
    console.log(attempt.error.message, attempt.request?.body);
  }
}

request is optional on RetryAttempt, so it is absent on attempts recorded before this field existed.

On this page