Debug

Error handling

Tool failure recovery, retry/fallback/circuit-breaker decorators, resume-on-error. Build agents that degrade gracefully instead of crashing on the first 429.

Your production agent makes 50,000 LLM calls a day. One in a thousand gets a 429. One in ten thousand gets a 500. At that volume, "throw and crash" is not a strategy — it's a paging incident waiting to happen. The library ships retry + fallback + circuit-breaker decorators, rules-based reliability, and resume-on-error so you can compose graceful degradation without writing per-provider error glue.

Errors at the boundary

Provider adapters surface the underlying SDK error — there is no wrapper LLMError class with err.code/err.retryable fields. (One failure is the deliberate exception: a request too large for the context window, which every vendor words differently and which is translated into a single typed error — see the next section.) A failed agent.run(...) rejects with the raw error, and observability captures it (the run-failed boundary fires so a monitor shows a humanized, actionable message rather than a stuck "thinking" state):

try {
  await agent.run({ message: 'Hello' });
} catch (err) {
  // `err` is the underlying provider/SDK error (or a wrapped one carrying
  // `.cause`). Inspect `err.status` / `err.statusCode` / `err.message` to
  // classify it yourself, or let the decorators below do it for you.
  console.error(err);
}

The retry/fallback decorators carry their OWN classification heuristics (HTTP 4xx vs 5xx vs AbortError), which you can override per call via shouldRetry / shouldFallback.

The request did not fit — ContextWindowExceededError

One failure is translated rather than passed through, because every vendor words it differently and none of them names the fix. When the provider refuses a request for exceeding the context window, the openai, anthropic, bedrock, browser-openai and browser-anthropic adapters all raise the same typed error:

import { Agent, ContextWindowExceededError, isContextWindowExceeded } from 'agentfootprint';

try {
  await agent.run({ message: 'summarise the fabric inventory' });
} catch (err) {
  if (err instanceof ContextWindowExceededError) {
    console.error(err.provider);      // 'openai'
    console.error(err.limitTokens);   // 272000  — when the vendor stated it
    console.error(err.actualTokens);  // 879073  — when the vendor stated it
  }
}

isContextWindowExceeded(err) is the same question without the import of the class, and err.code is the stable string ERR_CONTEXT_WINDOW_EXCEEDED.

The message carries the three fixes, cheapest first, because the numbers alone do not tell you which dial moves them:

  1. Cap oversized tool results where they are produced. One raw database dump handed back to the model is usually the whole overrun. Return a summary plus an id the model can ask about, not every row.
  2. .window(slidingWindow({ keepRecentTurns: 2 })) drops older rounds before the request is assembled — see Window strategies.
  3. .compaction() cannot rescue a span bigger than the window. Folding a span means sending that span to the summarizer, so a single round that already overflows fails in the summarizer call exactly as it did in the agent's. Compaction keeps a long conversation small; it does not repair one oversized round.

Detection is conservative on purpose

Only unmistakable vendor wording translates — context_length_exceeded, maximum context length, prompt is too long, exceed context limit, Input is too long for requested model, Input tokens exceed the configured limit, exceeds the maximum allowed input tokens. A rate limit ("tokens per min") is a different failure with a different fix and is never translated; neither is a max_tokens parameter validation error, nor an OUTPUT token cap worded the same way as an input one. Everything else reaches you as the provider error it always was.

Tool errors

When a tool's execute function throws, the error becomes a tool-result message tagged as an error. The LLM sees it and can decide to retry, try a different tool, or surface it to the user:

import { defineTool } from 'agentfootprint';

const riskyTool = defineTool({
  name: 'fetch_data',
  description: 'Fetch text from a URL',
  inputSchema: {
    type: 'object',
    properties: { url: { type: 'string' } },
    required: ['url'],
  },
  execute: async ({ url }: { url: string }) => {
    const res = await fetch(url);
    if (!res.ok) {
      // Throw to surface as tool-error to the LLM:
      throw new Error(`HTTP ${res.status}: ${res.statusText}`);
    }
    return await res.text();
  },
});

The agent's narrative captures the error event (agentfootprint.stream.tool_end with the error payload) so observability shows exactly which tool failed when. No silent swallowing.

Retry decorator

withRetry(provider, options?) wraps any LLMProvider with retry-on-transient-error and exponential backoff, and respects an AbortSignal. It lives at the agentfootprint/resilience subpath; the vendor-SDK providers (anthropic, openai, bedrock) live at agentfootprint/providers:

import { withRetry } from 'agentfootprint/resilience';
import { anthropic } from 'agentfootprint/providers';

const provider = withRetry(anthropic({ apiKey }), {
  maxAttempts: 5,         // total attempts incl. the first (default 3)
  initialDelayMs: 200,    // delay before first retry (default 200)
  backoffFactor: 2,       // multiplier per attempt (default 2 → 200ms, 400ms, 800ms)
  maxDelayMs: 10_000,     // cap on backoff delay (default 10s)
  // Override the default heuristic (skip AbortError + 4xx except 429):
  shouldRetry: (err, attempt) =>
    (err as { status?: number }).status !== 401 && attempt < 5,
  onRetry: (err, attempt, delayMs) =>
    console.warn(`retry ${attempt} in ${delayMs}ms`, err),
});

The default shouldRetry skips AbortError and HTTP 4xx (client mistakes don't benefit from retry) — except 429 Too Many Requests, which IS retried — and retries everything else (5xx, network errors, unknown shapes). Override it to implement per-error policies.

onRetry is for standalone use. Inside a run you don't need it: the decorator's retries surface automatically as agentfootprint.error.retried and agentfootprint.error.recovered, correlated to the run. See Resilience.

Fallback decorator

withFallback(primary, fallback, options?) swaps providers when the primary fails. Useful for cross-provider resilience — try Anthropic; if it's down, try OpenAI:

import { withFallback } from 'agentfootprint/resilience';
import { anthropic, openai } from 'agentfootprint/providers';

const provider = withFallback(
  anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
  openai({ apiKey: process.env.OPENAI_API_KEY! }),
  { onFallback: (err) => console.warn('primary failed, falling back:', err) },
);

The fallback activates when the primary throws an error meeting the fallback predicate. The default shouldFallback falls back on every error except AbortError — override it to gate on specific status codes. Successful streams pin to the active provider: once the primary's stream has yielded a chunk, an error after that re-throws rather than restarting on the fallback (no provider-flip mid-stream).

As with onRetry, onFallback is the standalone escape hatch — inside a run every real fallback also fires agentfootprint.fallback.triggered carrying which provider actually served.

For chaining three or more providers, use fallbackProvider(p1, p2, p3, ...) — sugar for nested withFallback that tries each in order, first success wins:

import { fallbackProvider } from 'agentfootprint/resilience';
import { anthropic, openai, mock } from 'agentfootprint/providers';

const provider = fallbackProvider(
  anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
  openai({ apiKey: process.env.OPENAI_API_KEY! }),
  mock({ reply: '[degraded] all upstream providers failed' }),
);

Compose: retry + fallback together

Decorators stack — each preserves the LLMProvider interface, so they're drop-in. Build a chain — try anthropic; on failure fall back to openai; the whole chain wrapped in retry:

import { withRetry, withFallback } from 'agentfootprint/resilience';
import { anthropic, openai } from 'agentfootprint/providers';

const provider = withRetry(
  withFallback(
    anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
    openai({ apiKey: process.env.OPENAI_API_KEY! }),
  ),
  { maxAttempts: 5 },
);

Add a circuit breaker per provider so retries stop hammering a downed vendor — once it trips, complete() fails fast with CircuitOpenError, which withFallback catches and routes onward:

import {
  withRetry,
  withFallback,
  withCircuitBreaker,
} from 'agentfootprint/resilience';
import { anthropic, openai } from 'agentfootprint/providers';

const provider = withRetry(
  withFallback(
    withCircuitBreaker(anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }), {
      failureThreshold: 5,
      cooldownMs: 30_000,
    }),
    withCircuitBreaker(openai({ apiKey: process.env.OPENAI_API_KEY! })),
  ),
  { maxAttempts: 3 },
);

Pass the composed provider to the agent: Agent.create({ provider, ... }).

Circuit breaker

withCircuitBreaker(provider, options?) (from agentfootprint/resilience) trips after N consecutive failures, opens for a cooldown, then half-open probes. It prevents thundering-herd retry on a downed provider — once OPEN, complete() throws CircuitOpenError in <1ms with no network round-trip:

import { withCircuitBreaker, CircuitOpenError } from 'agentfootprint/resilience';
import { anthropic } from 'agentfootprint/providers';

const provider = withCircuitBreaker(anthropic({ apiKey }), {
  failureThreshold: 5,            // open after 5 consecutive failures (default 5)
  cooldownMs: 30_000,             // stay open this long before probing (default 30s)
  halfOpenSuccessThreshold: 1,    // probe successes needed to close (default 1)
});

The breaker is per-instance, in-process (no Redis dependency). See the resilience guide for composing it with retry + fallback.

Resume from the failure point

agent.run() rejects with RunCheckpointError when a fault occurs mid-run, carrying both the .cause (the underlying error) and a JSON-serializable .checkpoint (the last-known-good conversation history). Persist it and call agent.resumeOnError(checkpoint) to replay the run with that history restored — the next iteration retries the call that originally failed:

resumeOnError is the error door: it retries the turn that failed, appending nothing. To continue a conversation with a NEW message — turn two of a chat — reach for agent.followUp(message) or run({ message, continueFrom }) instead. See Conversations.

import { Agent, canResume, RunCheckpointError } from 'agentfootprint';

try {
  const result = await agent.run({ message: 'long task' });
} catch (err) {
  if (err instanceof RunCheckpointError && canResume(err.cause)) {
    await checkpointStore.put(sessionId, err.checkpoint);
    // hours / restart later, after the vendor recovers:
    const checkpoint = await checkpointStore.get(sessionId);
    const result = await agent.resumeOnError(checkpoint);
  } else {
    throw err; // not a recoverable fault — propagate
  }
}

Some failures cannot be resumed — canResume

resumeOnError re-sends the checkpointed conversation to the same provider with the same credentials. For a transient fault that is exactly right. For a failure the request itself causes, it is a deterministic loop: a context-length refusal resumed into the identical 400, measured in a production field deployment, forever.

So canResume(error) classifies the cause, and RunCheckpointError's own message says plainly why resume cannot help instead of advertising it:

ClasscanResumeWhy
Transient — 5xx, 429, timeouts, socket errors, an open circuittrueThe same request can succeed later. This is what resume is for.
ContextWindowExceededError — or the same vendor sentence from a custom LLMProvider that never passed through an adapterfalseThe conversation is what did not fit; re-sending it fails identically. Shrink the request instead.
Rejected credentials — 401 / 403, UnrecognizedClientException, invalid api keyfalseThe replay carries the same key.
Malformed request — 400 / 404 / 422falseThe provider refused the request itself: an unknown model id, a bad tool schema, an unsupported parameter.

The checkpoint is still built and still carries the full conversation for every class, including the non-resumable ones — checkpoint.history is what was about to be sent, which is exactly what a post-mortem wants. It is evidence there, not a retry handle.

Rules-based reliability

For declarative retry/fail-fast policies inside the agent loop, configure Agent.create({...}).reliability({...}) with rules evaluated each iteration. A matching rule decides 'retry' vs 'fail-fast'; on fail-fast the run throws ReliabilityFailFastError (from agentfootprint/resilience):

import { Agent } from 'agentfootprint';
import { ReliabilityFailFastError } from 'agentfootprint/resilience';

const agent = Agent.create({ provider, model })
  .reliability({
    postDecide: [
      { when: (s) => s.errorKind === '5xx-transient' && s.attempt < 3,
        then: 'retry', kind: 'transient-retry' },
      { when: (s) => s.error !== undefined,
        then: 'fail-fast', kind: 'unrecoverable' },
    ],
  })
  .build();

try {
  await agent.run({ message: '...' });
} catch (e) {
  if (e instanceof ReliabilityFailFastError) console.log(e.kind, e.reason);
}

Anti-patterns

  • Don't catch a provider error and swallow it. Always re-throw or take a deliberate action (fallback, log, escalate).
  • Don't retry terminal errors. The decorators' default heuristic skips 4xx (except 429) and AbortError for a reason — retrying an auth error will not magically produce a key. If you override shouldRetry, keep that contract.
  • Don't put retry logic in the tool's execute. Tool errors are agent-visible by design — let the LLM decide whether to retry. If the tool itself needs retry on a flaky API, that's the tool's internal concern.

Next steps

On this page