Failure (throws)
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.
How errors are wrapped
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
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.
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.
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 forgettingnext()is a different failure mode — a short-circuit, reported on its own asE_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 calledawait 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.stashand 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.
turnEndalways fires;dispatchEndfires once dispatch has started;iterationEndfires for iterations that reach the end ofdispatchOutputPipeline. 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 is the full exception catalog.
Where to go next
- Abort —
ctx.abort(reason),ctx.abortSignal, and classifying abort traffic from outside the runner. - Pipelines — the hub.
- Composition —
next(), sequencing, where to open a gate. stash— cross-middleware state.- Failure — the full exception catalog.