Skip to content
4 min read · 845 words

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.

PipelineThrow wraps asEffect
turnInputPipelineE_INPUT_PIPELINE_ERRORSkips dispatch and output entirely. turnEnd still fires.
turnOutputPipelineE_OUTPUT_PIPELINE_ERRORSkips the rest of the turnOutputPipeline pipeline. turnEnd still fires.
dispatchInputPipelineE_DISPATCH_PIPELINE_ERRORDispatch nacks. dispatchEnd.status === 'nack'.
dispatchOutputPipelineE_DISPATCH_PIPELINE_ERRORDispatch 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.

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.

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; 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 is the full exception catalog.

Where to go next

  • Abortctx.abort(reason), ctx.abortSignal, and classifying abort traffic from outside the runner.
  • Pipelines — the hub.
  • Compositionnext(), sequencing, where to open a gate.
  • stash — cross-middleware state.
  • Failure — the full exception catalog.