Failure handling
When a run fails, know why — and react to it programmatically.
Everything in the SDK is safe to call in any state, but a run can still fail: the model provider rejects credentials, a tool throws, the network drops. Every failure carries a reason in a form that is safe to show a user — that is the error channel.
The public reason
snapshot.error is a PublicError:
interface PublicError {
kind: "llm" | "tool" | "condition" | "internal";
message: string;
}Two things are deliberately true of it:
kindis the answer most UIs need. "Misconfigured environment vs model outage" isllm(credentials rejected) vsllm(provider unreachable) vstool(your handler threw). A badge per kind is usually the whole UI.messageis safe by construction, not by redaction. Errors are mapped at production: an app handler's own text passes through (it's the app developer's content), and everything else is a curated server string. Provider bodies (which can name models and orgs), graph condition expressions, and internal detail never appear in a public message — they go to the server's error-level logs instead.
status: "failed" says that the run failed; error says why. A run that
fails at the run level with no node reporting a reason has error: undefined
— absent is honest, we don't guess.
Per-node, the same reason lives on NodeRun.error (reset at each retry of
the node), and on the run projection's FiredEdge.error when a producer
exists. A reopened failed conversation restores snapshot.error from the
server — a failure says why after a reload, exactly as it did live.
The two channels, deliberately
One failure, two channels — and which one fires depends on the layer. The
headless client (InlayChatClient) rejects with InlayRunError (a
failed run never resolves — and transport death shares the shape, so
try { await send() } catch catches the network and the agent). The
React layer defaults to resolving: send()/retry() settle, and the
failure lives on snapshot.error — because every UI consumer ended up
writing .catch(() => {}) by hand. Opt back into rejections with
useInlayChat({ failures: "reject" }) (or a per-call
send(text, { throwOnFailure: true })); misuse errors — a send mid-turn,
a send at a tool pause — reject in every mode, because they are
programming errors, not run outcomes.
- Scripts and headless callers live on the rejection — a failure must not read as a successful send.
- UI code renders
snapshot.erroranyway; the state, the reason, andcanRetryare identical in both modes.
Retrying a failed run
When a run fails at a node — the model provider timed out, a tool's
backend died — the server keeps a retry cursor, and retry() re-drives
that node's work as a new attempt:
try {
await send("build the recipe");
} catch (e) {
if (e instanceof InlayRunError && chat.canRetry) {
await showFailureAndWait(e.reason);
await chat.retry(); // rejects the same way if it fails again
}
}Three properties, deliberately:
- Nothing new enters the conversation. The failed node re-runs on exactly the state the failed run left — the retried model call sees the history it failed with (a partial streamed answer never enters history). The transcript keeps the failed attempt's rows; the retry appends new ones. It never silently replaces what the user watched fail.
canRetryis the server's answer, not a guess. Thedoneframe and a reopened conversation both carryretryable: true only when the failure left something to re-drive. An edge, cap, or graph failure has no node whose redo could succeed, so those advertisefalse— andretry()rejects without touching the network.- A retry that fails again stays retryable. The cursor survives a
re-failure, so a "try again later" loop works; the rejection is the same
InlayRunErrorshape as a failedsend().
Retrying a failure inside a container (a subgraph, loop, or fan-out) re-runs the whole container as one new attempt — earlier attempts' rows stay in the transcript beside the retried ones.
Or correct it instead
A user message sent to a failed conversation is the other way forward: it
re-drives the same failed node with the correction in history — "fix it and
try again", rather than a bare redo. The failed attempt's rows stay; the
re-driven node (an LLM rebuilds its request from history) sees your correction.
Pick per UX: retry() for "transient — try the same thing", send() for "the
input was wrong".
A failed run with no retry cursor (an edge, cap, or graph failure —
retryable: false) also takes a message: an interactive graph starts a fresh
turn from the entry over the accumulated record. A pipeline (no user_turn
anywhere) refuses the send (422) — there is nothing to consume a message, and
re-walking would re-run settled work.
When the request itself fails
Everything above is a run failing. A request can also fail before or
around a run — auth, conflicts, quota — and those surface as
InlayHttpError (thrown by send() and every other SDK call), with
typed accessors: isAuth (401 — the session provider re-mints and
retries once automatically), isForbidden (403 — scope or tenant),
isNotFound (404), isConflict (409 — a stream is already in flight).
The 429s split in two, and the difference matters:
isRateLimited— a transient throttle. HonorRetry-After, back off, retry.isQuotaExceeded— the end user's monthly allowance (plus credit headroom) is spent. Do not auto-retry: nothing changes until the user upgrades, tops up, or the period resets.err.quotacarries the figures (limitMicros/usedMicros,resetsAt) for your upgrade CTA — the full pattern is in Billing.
What this is not (yet)
A retry re-drives from the failed node's start, not from mid-stream —
a model call that streamed half an answer and died starts that answer
over (the partial stays visible as the failed attempt's row; it does not
resume mid-token). Container-internal resume (continuing a loop at the
item it failed on, rather than re-running the loop) is future work,
tracked in projects/inlay/backlog.md.