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/5xx/network)}type ErrorTier = "retry" | "fail"
createClient({ retries?, retryBaseMs?, timeoutMs?, 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/5xx/network) retries within budget, everything
else fails immediately. 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 onError specifically when the default retry set 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.
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/5xx/network). |
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. |
timeoutMs |
number |
Whole-run deadline; aborts the in-flight request when exceeded. |
onError |
(info: ErrorInfo) => ErrorTier |
Classify each failed attempt. Omit ⇒ the default classifier. |
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.