---
url: 'https://adk.nht.io/the-loop/failure.md'
description: >-
  Exception codes, validation errors, gate failures, abort semantics, and what
  ack / nack actually mean.
---

# Failure

## LLM summary — Failure

* Every exception has a stable `E_*` code constructed via `createException(code, message, anchor, status, fatal)`. The `fatal` flag determines whether the exception throws out of `run()` (fatal) or emits as `error` on the observability bus (non-fatal).
* **Fatal = programming error.** Throws synchronously at the seam. There is no path back into the runner.
* **Non-fatal = runtime failure.** Emits on observability `error`; the failing pipeline stage is skipped; `turnEnd` / `dispatchEnd` still fire.
* Surface → code map:
  * Construction: `E_INVALID_TURN_RUNNER_CONFIG` (fatal).
  * Turn input: `E_INVALID_TURN_CONTEXT` (fatal).
  * Pipelines: `E_INPUT_PIPELINE_ERROR`, `E_OUTPUT_PIPELINE_ERROR`, `E_DISPATCH_PIPELINE_ERROR` (non-fatal — both dispatch pipelines share one code). `E_PIPELINE_SHORT_CIRCUITED` is **not a thrown exception** — it is a *detection condition* the runner emits on the observability `error` bus when a middleware returns without calling `next()` and the turn was not aborted. Nothing throws it; the runner constructs and emits the code itself.
  * Dispatch: `E_LLM_EXECUTION_EXECUTOR_ERROR` (non-fatal, wraps executor throws).
  * Gates: `E_INVALID_TURN_GATE_RESOLUTION` (thrown synchronously in resolver), `E_TURN_GATE_ABORTED`, `E_TURN_GATE_TIMEOUT` (gate-promise rejection).
  * Tools: `E_TOOL_ALREADY_REGISTERED` (registration collision under default `onCollision: 'throw'`), `E_INVALID_TOOL_ARGS` (argument validation), `E_TOOL_DOWNSTREAM_ERROR` (handler/downstream failure).
  * Primitives: `E_INVALID_INITIAL_MESSAGE_VALUE`, `E_INVALID_INITIAL_MEMORY_VALUE`, `E_INVALID_INITIAL_THOUGHT_VALUE`, `E_INVALID_INITIAL_TOOL_CALL_VALUE`, `E_INVALID_INITIAL_RETRIEVABLE_VALUE`, `E_INVALID_INITIAL_TOOL_VALUE`, etc. (fatal — bad construction).
  * Spool/artifacts: `E_NOT_A_SPOOL_READER` (wrap-site validation).
* Dispatch terminal status: `'ack'` (someone called `ctx.ack()`), `'nack'` (someone called `ctx.nack(err)` OR executor/middleware threw a non-abort error — `dispatchEnd.error` carries the cause), `'aborted'` (abort signal fired; **no `error` event** — abort is not an error).
* Abort semantics: `AbortSignal` firing discards the pending delta queue (no partial writes), breaks the loop, sets `dispatchEnd.status = 'aborted'`. The `turnEnd` event still fires.
* Signalling is **not** silently idempotent: a second `ack()` or `nack()` throws `E_LLM_EXECUTION_ALREADY_SIGNALLED`. First call wins; guard with `if (!ctx.isSignalled)` when multiple seams may signal.
* Common mistake: try/catch around `await runner.run(...)`. `run()` resolves on pipeline failure; the only way to observe failure is the observability `error` event. Wire the observer.

ADK validates eagerly, names every exception with a stable error code, and does not swallow errors. The runner's behavior on failure is mechanical: validation throws at the seam where the bad input arrived; pipeline failures surface as `error` events on the observability bus; dispatch failures end the dispatch with `dispatchEnd.status: 'nack'`; abort short-circuits silently.

The per-code reference — every `E_*` code organized by seam, with fatality, behavior, and the nuances that matter in production — lives in the [Exception Reference](/api/@nhtio/adk/exceptions/).

## Exception anchors

Every exception is constructed by [`createException`](https://adk.nht.io/api/@nhtio/adk/factories/functions/createException). The `code` is the stable identifier (`E_*`). The `fatal` flag controls whether the runner throws out of `run()` or emits on the `error` bus.

::: danger Fatal vs non-fatal is a structural distinction

* **Fatal exceptions** indicate programming errors. They throw synchronously at the seam where the bad input arrived. There is no path back into the runner.
* **Non-fatal exceptions** indicate runtime failures. They are emitted on the observability `error` event and the pipeline stage that produced them is skipped; the turn / dispatch continues to its terminal event.

:::

## Abort is not an error

::: warning Abort is a settlement outcome, not a failure
When the turn-level abort signal fires (or middleware throws an `AbortError`), the pipeline short-circuits silently:

* No `error` event is emitted.
* `turnEnd` still fires.
* `dispatchEnd.status` is `'aborted'` (if the abort occurred during dispatch).
* Pending deltas inside dispatch are discarded — partial writes never reach storage.

The consumer's abort handler is responsible for any user-visible signal. The runner does not interpret abort as failure; abort is a settlement outcome on its own.
:::

## What you do not catch

::: danger `try`/`catch` around `run()` is not enough
`run()` resolves with `void` and rejects only with the **fatal** exceptions listed above (validation errors at construction and entry). Everything else surfaces through events:

* Non-fatal pipeline errors → `runner.observe('error', ...)`.
* Dispatch settlement → `runner.observe('dispatchEnd', ev => ev.status === 'nack' ? ev.error : ...)`.
* Tool errors → either `toolExecutionEnd` (with `isError: true`) on the observability bus, or `toolCall` (with `isError: true`) on the functional bus.
* Gate failures → the [`TurnContext.waitFor`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-waitfor) promise rejects; observability sees `turnGateClosed` with the result.

If you wrap `run()` in a `try { } catch { }`, you are catching programmer errors (invalid config, invalid context). For runtime failures, listen to events.
:::
