ErrorInfo
JavaScript · package toolnexus · SPEC §8 · js/src/client.ts
interface ErrorInfo { error?: unknown // the thrown network/transport error, when it wasn't an HTTP response status?: number // the HTTP status, when it was a non-ok response attempt: number // zero-based attempt index (0 = first try) retryable: boolean // whether status/error is in the default retryable set // (429, 500, 502, 503, 504, 529, or any network error)}type ErrorTier = "retry" | "fail"
createClient({ retries?, retryBaseMs?, timeoutMs?, retryableStatuses?: readonly number[], onError?: (info: ErrorInfo) => ErrorTier, … })The resilience layer around every LLM call: retries with exponential backoff + jitter (honoring
Retry-After), a whole-run deadline that aborts the in-flight request, and — via onError — the
classifier that decides retry-or-fail for each failed attempt. Omit onError and you get the
default classifier: the normally-retryable set — 429, 500, 502, 503, 504, 529, and any
network error — retries within budget, everything else fails immediately. That set is an
exhaustive enumeration, not “429 plus any 5xx”: a 501, a 507 or a Cloudflare 520 is terminal
by default. That default is byte-identical to what the client did before onError
existed.
When to use it
Section titled “When to use it”The built-in retry/timeout behavior (retries, retryBaseMs, timeoutMs) covers the common case
with no code. Reach for retryableStatuses when your provider emits a transient status the
default set doesn’t cover, and for onError specifically when the default retry decision is wrong
for your provider or your budget — e.g. treat a 400 from a flaky gateway as retryable, or stop
retrying a 429 immediately because you’re already over cost budget for this run.
Widening the set with retryableStatuses
Section titled “Widening the set with retryableStatuses”A Cloudflare-fronted origin answers 520–527 when the edge can’t get a clean response from the
origin — transient, and not in the default set. List those statuses and they classify as retryable:
const client = createClient({ model, apiKey, retryableStatuses: [520, 521, 522, 523, 524, 525, 526, 527] })retryableStatuses is additive to the defaults, never a replacement — it can only make more
statuses retryable, never fewer, so listing it cannot accidentally drop 429 and lose
Retry-After handling along with it. It sets the default classification only: onError still
runs on every failed attempt and has the final say, so an onError returning "fail" overrides a
status the host itself listed. Retry-After handling is unaffected.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — default retries recover a transient 503
Section titled “1. The smallest useful call — default retries recover a transient 503”No onError set: two 503s, then the third attempt succeeds — the default classifier’s behavior,
unchanged.
import assert from "node:assert"import http from "node:http"import { createClient, createToolkit } from "toolnexus"
let hits = 0const server = http.createServer((req, res) => { hits++ if (hits < 3) { res.writeHead(503); res.end("busy"); return } res.writeHead(200, { "content-type": "application/json" }) res.end(JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))})await new Promise<void>((r) => server.listen(0, "127.0.0.1", r))const port = (server.address() as any).port
const tk = await createToolkit({})const client = createClient({ baseUrl: `http://127.0.0.1:${port}`, style: "openai", model: "stub", apiKey: "test-key", retries: 3, retryBaseMs: 5 })
const res = await client.run("hi", { toolkit: tk })assert.equal(res.text, "ok")assert.equal(hits, 3, "two 503s retried, third succeeded")
await tk.close()server.close()console.log("ok:", res.text, hits)2. A realistic case — a custom classifier overrides the default in both directions
Section titled “2. A realistic case — a custom classifier overrides the default in both directions”onError treats a normally-terminal 400 as retryable (within budget), and a normally-retryable
429 as an immediate fail — the inverse of the built-in policy, entirely by the host’s own rule.
import assert from "node:assert"import http from "node:http"import { createClient, createToolkit } from "toolnexus"import type { ErrorInfo, ErrorTier } from "toolnexus"
// Server A: always 400 — normally terminal, this classifier retries it up to budget.let hitsA = 0const serverA = http.createServer((req, res) => { hitsA++; res.writeHead(400); res.end("bad") })await new Promise<void>((r) => serverA.listen(0, "127.0.0.1", r))const portA = (serverA.address() as any).port
const tk = await createToolkit({})const retryOn400 = (info: ErrorInfo): ErrorTier => (info.status === 400 ? "retry" : (info.retryable ? "retry" : "fail"))const clientA = createClient({ baseUrl: `http://127.0.0.1:${portA}`, style: "openai", model: "stub", apiKey: "test-key", retries: 2, retryBaseMs: 5, onError: retryOn400 })await assert.rejects(() => clientA.run("hi", { toolkit: tk }), /LLM 400/)assert.equal(hitsA, 3, "1 initial attempt + 2 retries, all on a normally-terminal 400")serverA.close()
// Server B: always 429 — normally retryable, this classifier fails immediately (budget-conscious).let hitsB = 0const serverB = http.createServer((req, res) => { hitsB++; res.writeHead(429); res.end("slow down") })await new Promise<void>((r) => serverB.listen(0, "127.0.0.1", r))const portB = (serverB.address() as any).portconst failFast: (info: ErrorInfo) => ErrorTier = () => "fail"const clientB = createClient({ baseUrl: `http://127.0.0.1:${portB}`, style: "openai", model: "stub", apiKey: "test-key", retries: 5, retryBaseMs: 5, onError: failFast })await assert.rejects(() => clientB.run("hi", { toolkit: tk }), /LLM 429/)assert.equal(hitsB, 1, "onError:fail skipped every retry, even for a normally-retryable status")serverB.close()
await tk.close()console.log("ok:", hitsA, hitsB)3. The full surface — attempt/retryable inspected, plus a deadline that cancels cleanly
Section titled “3. The full surface — attempt/retryable inspected, plus a deadline that cancels cleanly”onError sees the zero-based attempt and the default retryable verdict on every failure —
useful for logging or attempt-dependent policy. timeoutMs bounds the whole run and aborts the
in-flight request; a hung server past that deadline never gets a chance to retry its way out.
import assert from "node:assert"import http from "node:http"import { createClient, createToolkit } from "toolnexus"import type { ErrorInfo } from "toolnexus"
// Server: 429 twice, then succeeds — inspect what onError sees on each attempt.let hits = 0const server = http.createServer((req, res) => { hits++ if (hits <= 2) { res.writeHead(429); res.end("slow"); return } res.writeHead(200, { "content-type": "application/json" }) res.end(JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))})await new Promise<void>((r) => server.listen(0, "127.0.0.1", r))const port = (server.address() as any).port
const seen: ErrorInfo[] = []const tk = await createToolkit({})const client = createClient({ baseUrl: `http://127.0.0.1:${port}`, style: "openai", model: "stub", apiKey: "test-key", retries: 3, retryBaseMs: 5, onError: (info) => { seen.push(info); return info.retryable ? "retry" : "fail" },})const res = await client.run("hi", { toolkit: tk })assert.equal(res.text, "ok")assert.deepEqual(seen.map((i) => i.attempt), [0, 1])assert.ok(seen.every((i) => i.status === 429 && i.retryable === true))await tk.close()server.close()
// A server that never answers — timeoutMs aborts the run instead of hanging forever.const hungServer = http.createServer(() => { /* never respond */ })await new Promise<void>((r) => hungServer.listen(0, "127.0.0.1", r))const hungPort = (hungServer.address() as any).portconst tk2 = await createToolkit({})const bounded = createClient({ baseUrl: `http://127.0.0.1:${hungPort}`, style: "openai", model: "stub", apiKey: "test-key", timeoutMs: 150, retries: 5, retryBaseMs: 500 })const t0 = Date.now()await assert.rejects(() => bounded.run("hi", { toolkit: tk2 }))const elapsed = Date.now() - t0assert.ok(elapsed < 2000, `timeoutMs did not bound the run (took ${elapsed}ms)`)await tk2.close()hungServer.close()
console.log("ok:", seen.length, elapsed)Fields
Section titled “Fields”| Field | Type | What it means |
|---|---|---|
error |
unknown |
The thrown network/transport error, when the failure was not an HTTP response. |
status |
number |
The HTTP status, when the failure was a non-ok response. |
attempt |
number |
Zero-based attempt index (0 = first try). |
retryable |
boolean |
Whether status/error is in the default retryable set — 429, 500, 502, 503, 504, 529, or any network error — as widened by retryableStatuses. |
onError(info: ErrorInfo) => "retry" | "fail" — a "retry" is always bounded by retries, so a
classifier cannot make the client loop unbounded. There is no "suspend" tier; use
waitFor for a user-action pause instead.
Options (on createClient)
Section titled “Options (on createClient)”| Option | Type | What it does |
|---|---|---|
retries |
number |
Transient-error retries. Default 2. |
retryBaseMs |
number |
Base backoff, exponential + jitter, honoring Retry-After. Default 500. |
retryableStatuses |
readonly number[] |
Extra HTTP statuses to classify as retryable, added to the default 429/500/502/503/504/529. It can only widen the set, never shrink it; onError still has the final say. |
timeoutMs |
number |
Whole-run deadline; aborts the in-flight request when exceeded. |
onError |
(info: ErrorInfo) => ErrorTier |
Classify each failed attempt. Omit ⇒ the default classifier. |
The classifier has the same backoff base
Section titled “The classifier has the same backoff base”Classifier carries the same knob under the same name: ClassifierOptions.retryBaseMs (default 500, base * 2 ** attempt, no jitter, Retry-After still wins). See createClassifier.
See also
Section titled “See also”createClient— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.Client.run— Send a prompt, let the loop call tools until the model stops, get a RunResult.Client.stream— The streaming loop: text deltas, tool-call events, and suspension events as they happen.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.