InlayDocs

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:

  • kind is the answer most UIs need. "Misconfigured environment vs model outage" is llm (credentials rejected) vs llm (provider unreachable) vs tool (your handler threw). A badge per kind is usually the whole UI.
  • message is 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.error anyway; the state, the reason, and canRetry are 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.
  • canRetry is the server's answer, not a guess. The done frame and a reopened conversation both carry retryable: 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 advertise false — and retry() 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 InlayRunError shape as a failed send().

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. Honor Retry-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.quota carries 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.

On this page