---
url: 'https://adk.nht.io/the-loop/pipelines/throws.md'
description: >-
  How throws in middleware are wrapped and emitted, why post-steps run on the
  error path, and the commit-vs-rollback pattern for path-aware cleanup.
---

# Failure (throws)

## LLM summary — Failure (throws)

* Throws from a middleware are wrapped and emitted on the observability bus as the matching pipeline error code: `turnInputPipeline` → [`E_INPUT_PIPELINE_ERROR`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_INPUT_PIPELINE_ERROR), `turnOutputPipeline` → [`E_OUTPUT_PIPELINE_ERROR`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_OUTPUT_PIPELINE_ERROR), both dispatch pipelines (`dispatchInputPipeline` and `dispatchOutputPipeline`) share [`E_DISPATCH_PIPELINE_ERROR`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_DISPATCH_PIPELINE_ERROR). `run()` does not throw them. If no `observe('error', ...)` is wired, the error is gone.
* A throw skips the rest of *its own* middleware body, plus every downstream middleware that had not yet been entered. What it does **not** skip is the upstream: every upstream `await next()` in the same pipeline still resolves, every upstream post-step still runs, and the throw lands once on the observability bus.
* Post-steps run on the error path. The post-step *is* the cleanup; `try`/`finally` is unnecessary. What the post-step cannot do is tell which path it is on — `await next()` resolves the same way after a throw as after a success. To commit vs. roll back, set a `ctx.stash` flag near the bottom of the pipeline (only the success path reaches it) and read it from the post-step.
* There is no separate cleanup hook, and there will not be one. A success-only callback would leak on the error path; two cleanup channels would split the mental model. Cleanup belongs in a post-step.
* Abort is covered on [Abort](./abort) — different channel, different observability surface, different rules.

[Composition](./composition) covers the function shape, `next()` semantics, and where to open a gate. This page picks up where the happy path ends: what happens when a middleware throws, what else in the pipeline still runs, and how to tell commit from rollback in a post-step. Abort is a different beast and lives on its own page — [Abort](./abort).

## How errors are wrapped

::: warning Throws are emitted, not propagated
The runner catches a throw, wraps it as the matching pipeline-error code, and emits `error` on the observability bus. `run()` resolves. No observer wired → the error is gone.
:::

| Pipeline | Throw wraps as | Effect |
| --- | --- | --- |
| `turnInputPipeline` | `E_INPUT_PIPELINE_ERROR` | Skips dispatch and output entirely. `turnEnd` still fires. |
| `turnOutputPipeline` | `E_OUTPUT_PIPELINE_ERROR` | Skips the rest of the `turnOutputPipeline` pipeline. `turnEnd` still fires. |
| `dispatchInputPipeline` | `E_DISPATCH_PIPELINE_ERROR` | Dispatch nacks. `dispatchEnd.status === 'nack'`. |
| `dispatchOutputPipeline` | `E_DISPATCH_PIPELINE_ERROR` | Dispatch nacks. `dispatchEnd.status === 'nack'`. |

Both dispatch pipelines share one error class — the runner does not split input vs. output at this layer.

A throw skips the rest of *its own* middleware body, plus every downstream middleware that had not yet been entered — the pipeline's post-steps for already-entered upstream middlewares still run. Every upstream `await next()` in this pipeline resolves as if the rest of the pipeline had completed; every upstream post-step still runs. The throw lands once, on the observability bus, as the matching pipeline-error code.

## Post-steps run on the error path

::: warning The post-step *is* the cleanup
This is the opposite of `try`/`catch` propagation. A pre-step that acquires a resource and a post-step that releases it will release on every path — happy or error. `try`/`finally` is unnecessary; the post-step *is* the `finally`. What the post-step *cannot* do is tell which path it is on. `await next()` resolves the same way after a throw as after a success. If your post-step needs to commit vs. roll back, surface that through the context — a flag, a recorded outcome — before the throw could land. `next()` itself will never say.
:::

Concretely: a middleware that opens a transaction in the pre-step and closes it in the post-step records success at the bottom of the pipeline (closest to dispatch), and the post-step commits or rolls back based on that flag.

```ts
const transactionalMiddleware = async (ctx, next) => {
  const tx = await db.begin()
  ctx.stash.set('tx.handle', tx)
  ctx.stash.set('tx.committed', false)
  await next()
  // Post-step runs on success AND on throw. Decide which by reading the flag.
  if (ctx.stash.get('tx.committed') === true) {
    await tx.commit()
  } else {
    await tx.rollback()
  }
}

// Somewhere later in the pipeline, a middleware closer to dispatch sets the flag
// once the work that needs the transaction has succeeded.
const recordSuccess = async (ctx, next) => {
  await next()
  ctx.stash.set('tx.committed', true)
}
```

The flag is set only on the success path, because a throw downstream skips the line that would set it. The transaction middleware's post-step then has an unambiguous signal — flag set means commit, flag unset means roll back. The same shape works for any "commit vs. roll back" pair: lease/release, increment/decrement, write/undo. Surface the outcome through the context; let the post-step read it.

::: danger There is no separate cleanup hook — and there will not be one
A recurring ask is "expose a runner-level callback that fires after each pipeline, so cleanup doesn't have to live in a middleware." The answer is no, and here is why:

* **Except for the aborting middleware itself — which must handle cleanup inline because it returns before calling `next()` — the post-step covers every path that needs cleanup.** It runs on success, on a throw downstream, and on abort. (Downstream forgetting `next()` is a different failure mode — a short-circuit, reported on its own as [`E_PIPELINE_SHORT_CIRCUITED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_PIPELINE_SHORT_CIRCUITED); the upstream pipeline still unwinds normally and post-steps still run.) On abort, the runner's wrapper skips *not-yet-entered* downstream bodies — but middlewares that already called `await next()` resume normally when downstream returns, so their post-steps fire.
* **A success-only callback is worse than no callback.** Any hook that runs only on the happy path silently leaks resources the day a downstream middleware throws. "Cleanup hook that only cleans up when nothing went wrong" is a category error.
* **Two channels split the mental model.** Cleanup in a post-step reads `ctx.stash` and is ordered by the array. Cleanup in a side-channel callback does neither. Reviewing a turn would mean asking "is this cleanup in a post-step or a hook, and which paths does each cover" on every PR. One channel is the whole point.
* **"The pipeline finished" telemetry already exists.** `turnEnd` always fires; `dispatchEnd` fires once dispatch has started; `iterationEnd` fires for iterations that reach the end of `dispatchOutputPipeline`. If the use case is "log when this pipeline ends", the event bus answers it. If the use case is "release a resource", the post-step is the answer.

If your cleanup is hard to write as a post-step, the friction is almost always a missing flag on `ctx.stash`, not a missing hook. Set the flag; let the post-step read it.
:::

[Failure](../failure) is the full exception catalog.

## Where to go next

* [Abort](./abort) — `ctx.abort(reason)`, `ctx.abortSignal`, and classifying abort traffic from outside the runner.
* [Pipelines](../pipelines) — the hub.
* [Composition](./composition) — `next()`, sequencing, where to open a gate.
* [`stash`](./stash) — cross-middleware state.
* [Failure](../failure) — the full exception catalog.
