just-another-sdk

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.

SituationBehaviour
A tool throwsDoes not throw. Fed back to the model as a tool result; the run completes
Invalid tool argumentsDoes not throw. The model gets a per-field error and retries
A tool times outDoes not throw. The model is told and carries on
maxTurns reachedDoes not throw. Returns with stopReason: 'max_turns'
Provider is down, rate-limited, or rejects the keyThrows
The run is cancelled or times outThrows
A tool fails under onToolError: 'throw'Throws
outputSchema unsatisfied after repairsThrows InvalidOutputError, with the per-field issues
A tool guardrail blocks a callDoes not throw. The model gets an error result and routes around it
An input or output guardrail rejectsThrows GuardrailError, naming the guardrail
A tool guardrail requires approvalThrows ApprovalRequiredError, carrying a resumable suspension
MisconfigurationThrows, 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
})
OptionDefaultDescription
maxRetries2Extra attempts after the first. 0 disables retries.
retryDelayMs250Base of the exponential curve.
maxRetryDelayMs10_000Ceiling on any single wait.
retryOnReplaces 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 maxRetryDelayMs is not honoured. The call fails immediately instead, with retryAfterMs still on the error so you can schedule it yourself. Blocking an await for 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.1s

Messages 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/keys
InvalidToolInputError: Tool "get_weather" was called with invalid input:
  • location.city: Expected string, received number

Secret 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 redacted

This 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 })

Next

On this page