Errors
One error type, machine-readable codes, and where secrets are guaranteed not to appear.
One type, always
Everything the SDK throws is an AgentError. Catch one type, then switch on
code:
import { isAgentError } from 'just-another-sdk'
try {
const result = await agent.run(prompt)
} catch (error) {
if (!isAgentError(error)) throw error // not ours — rethrow
console.error(error.code) // 'rate_limit_error'
console.error(error.retryable) // true
console.error(error.message) // includes an actionable hint
console.error(error.toJSON()) // log-safe structured form
}Provider errors never escape unwrapped, so you do not need a separate branch per vendor.
What throws, and what does not
This distinction is the most important thing on this page.
| Situation | Behaviour |
|---|---|
| A tool throws | Does not throw. Fed back to the model as a tool result; the run completes |
| Invalid tool arguments | Does not throw. The model gets a per-field error and retries |
| A tool times out | Does not throw. The model is told and carries on |
maxTurns reached | Does not throw. Returns with stopReason: 'max_turns' |
| Provider is down, rate-limited, or rejects the key | Throws |
| The run is cancelled or times out | Throws |
A tool fails under onToolError: 'throw' | Throws |
outputSchema unsatisfied after repairs | Throws InvalidOutputError, with the per-field issues |
| A tool guardrail blocks a call | Does not throw. The model gets an error result and routes around it |
| An input or output guardrail rejects | Throws GuardrailError, naming the guardrail |
| A tool guardrail requires approval | Throws ApprovalRequiredError, carrying a resumable suspension |
| Misconfiguration | Throws, at construction time |
A RunResult in hand therefore always means the agent produced something.
Codes
Prop
Type
Every code has a matching class if you prefer instanceof:
import { RateLimitError, AuthenticationError } from 'just-another-sdk'
catch (error) {
if (error instanceof RateLimitError) await sleep(error.retryAfterMs ?? 1000)
if (error instanceof AuthenticationError) alertOncall()
}Retries
Retryable model calls are retried automatically. The defaults are on — a 429 or a dropped connection recovers without any code from you:
const agent = new Agent({
name: 'assistant',
model: openrouter('anthropic/claude-opus-5'),
maxRetries: 2, // the default: up to 3 attempts per model call
})| Option | Default | Description |
|---|---|---|
maxRetries | 2 | Extra attempts after the first. 0 disables retries. |
retryDelayMs | 250 | Base of the exponential curve. |
maxRetryDelayMs | 10_000 | Ceiling on any single wait. |
retryOn | — | Replaces the default error.retryable predicate. |
maxRetries can also be overridden per run: agent.run(prompt, { maxRetries: 0 }).
Retries are per model call, not per run: a failed attempt is replayed with the identical request, so there is no partial state to unwind. Tool failures are deliberately outside this — they are fed back to the model as results, which is a better recovery mechanism than blind repetition.
The backoff curve
Exponential with full jitter — the wait is a random value in
[0, min(base × 2ⁿ, cap)). Jitter spreads a thundering herd across the whole
window instead of clustering every client at the end of it.
Two rules govern a provider's Retry-After:
- It is a floor, not a value. A small jitter is added on top. Sleeping exactly what the server asked guarantees every rate-limited client returns in the same millisecond.
- One longer than
maxRetryDelayMsis not honoured. The call fails immediately instead, withretryAfterMsstill on the error so you can schedule it yourself. Blocking anawaitfor five minutes because a gateway said so is worse than an actionable failure.
Cancellation takes effect during a backoff, not after it — an aborted run does not sit out a ten-second wait.
What is never retried
Cancellation, always. Beyond that the retryable flag decides, and retryOn
replaces it when your provider needs different rules:
new Agent({
name: 'assistant',
model: openrouter('anthropic/claude-opus-5'),
// This gateway 400s on transient capacity problems.
retryOn: (error) => error.retryable || error.message.includes('capacity'),
})Model fallback
fallbacks is a chain tried in order once the primary is spent:
const agent = new Agent({
name: 'assistant',
model: openrouter('anthropic/claude-opus-5'),
fallbacks: [openrouter('openai/gpt-5'), openrouter('google/gemini-3-pro')],
})- A fallback also takes over on a non-retryable failure. A bad key or an unknown model on the primary is exactly when a second vendor should serve; retrying it could never have worked.
- Cancellation never falls back.
- The chain resets to the primary each turn, so a transient outage cannot permanently demote your preferred model.
- If the whole chain fails, the run rejects with the last provider's error — the real failure, not an aggregate wrapper.
Which model actually served a turn is recorded on steps[].modelId, and both
model.retry and model.fallback appear in the event
stream so a trace shows what happened:
▶ run_m9x2k1p assistant · anthropic/claude-opus-5
⟳ retry 1/3 · rate_limit_error · waiting 412ms
⇄ fallback → openai/gpt-5 · after provider_error
✔ finish · 1 turn · 412 in / 63 out · 2.1sMessages carry the fix
Errors include a concrete next step where one exists, because the most common "bug" is a setup mistake:
ConfigurationError: No OpenRouter API key found.
→ Set the OPENROUTER_API_KEY environment variable, or pass
openrouter('model-id', { apiKey }). Create a key at https://openrouter.ai/keysInvalidToolInputError: Tool "get_weather" was called with invalid input:
• location.city: Expected string, received numberSecret safety
The SDK holds itself to one rule: an API key never appears in a thrown error, an emitted event, or a printed trace.
// Given openrouter('m', { apiKey: 'sk-or-v1-secret…' }) and a 401:
error.message // '… rejected the API key: invalid api key' ← no key
error.stack // no key
error.toJSON() // no key
error.details // request headers, with authorization redactedThis holds for tool output too. The console tracer redacts before printing, so a tool that handles credentials cannot leak them into your terminal or CI log:
const leaky = tool({
name: 'get_config',
description: 'Fetch service config.',
execute: () => ({ apiKey: 'sk-live-…', region: 'eu-west-1' }),
})
// Printed trace:
// → {"apiKey":"[redacted]","region":"eu-west-1"}There is a test suite asserting all of the above, including that a leaked key cannot survive a circular-reference or deeply-nested payload.
If you build your own logging, run values through the same helper:
import { redact, redactHeaders } from 'just-another-sdk'
logger.debug('tool output', redact(output))
logger.debug('request', redactHeaders(headers))Redaction is defence in depth, not a licence to log freely. It recognises the
common vendor key formats (sk-, sk-ant-, AIza, ghp_, xox*, bearer
tokens) and sensitive field names — but a bespoke secret format it has never
seen will pass through. Do not log raw provider payloads.
Cancellation
Cancellation is a standard AbortSignal, and it reaches all the way down — the
in-flight model request and every running tool are aborted, not merely
abandoned:
const controller = new AbortController()
request.signal.addEventListener('abort', () => controller.abort()) // user closed the tab
try {
await agent.run(prompt, { signal: controller.signal })
} catch (error) {
if (isAgentError(error) && error.code === 'aborted') return // expected
throw error
}A whole-run deadline is timeoutMs:
await agent.run(prompt, { timeoutMs: 30_000 })