error

error(handler: (error: unknown, exchange: Exchange, forward: ForwardFn) => unknown | Promise<unknown>, options?: { schema?: StandardSchemaV1 }): this

Define a catch-all error handler for unhandled errors in the route's step pipeline. Must be called before .from(). When any step throws an unhandled error, this handler is invoked instead of the default log-and-swallow behavior. The pipeline does not resume after the handler runs; its return value becomes the route's final exchange body.

This is a route-level configuration, not a step wrapper. Convention is to place it near the top with other route-level options like id() and batch().

The error handler receives:

  • error: The thrown error (unknown, not necessarily a RoutecraftError)
  • exchange: The exchange at the point of failure
  • forward: A function to delegate to another route via the direct adapter: (endpoint: RegisteredDirectEndpoint, payload: unknown) => Promise<unknown>

The error handler can:

  • Return nothing to silently handle the error
  • Return a value to use as the route's final exchange body
  • Call forward(endpoint, payload) to delegate to a direct route and return its result
  • Rethrow the error to propagate it to the context level
// Log and swallow
craft()
  .id('with-error-handler')
  .error((error, exchange) => {
    exchange.logger.error(error, 'Step failed');
  })
  .from(source())
  .process(mightFail)
  .to(destination)

// Forward to a fallback route via the direct adapter
craft()
  .id('with-forward')
  .error((error, exchange, forward) => {
    return forward('error-route', { reason: (error as Error).message })
  })
  .from(source())
  .process(mightFail)
  .to(destination)

// Rethrow critical errors to context level
craft()
  .id('rethrow-critical')
  .error((error) => {
    if (error instanceof RoutecraftError && error.code === 'CRITICAL') throw error;
    // Non-critical errors are swallowed
  })
  .from(source())
  .process(mightFail)
  .to(destination)

Error handling levels:

  1. Step level: an .error() wrapper chained after .from(), covering one step
  2. Route level: this handler, catching everything in the route (including tap errors via events)
  3. Plugin level: a hook in the chain's error slot, declared by a plugin and reached only where the two above gave up

The three are consulted innermost first and the first to decide wins. A route that handles its own failures is never overridden by a plugin.

The route ring and the error slot run outside the route's resilience wrappers; a step-scope .error() does not. A failure inside a .retry(), .timeout(), .circuitBreaker() or .concurrency() segment surfaces to the wrapper first, so those two are consulted once the attempts are exhausted rather than once per attempt. That is what a route author declaring .retry() means by it, and it is why a park lands after the retries rather than on attempt one: nobody should be asked to approve something an automatic retry would have fixed.

A step-scope wrapper sits in the step list, inside the segment, so it runs on every failed attempt. The two are useful for different jobs: recover a flaky step per attempt with a step-scope .error(), and decide what a route's exhausted failure means with the route handler or an error slot hook.

Recovery directives

Instead of a recovery body the handler may return a branded directive built with the recovery helpers. A plain (unbranded) return value keeps its meaning: it becomes the recovered exchange body.

DirectiveWhat it does
recovery.drop(reason?)Discards the exchange. Emits route:exchange:dropped with reason; route:exchange:completed does not fire.
recovery.rethrow()Propagates the original error, exactly as if the handler had thrown it.
recovery.defer(request)Parks the exchange durably and answers with the Deferred acknowledgment.

recovery.defer(request)

Turns an error into a deferral. This is what lets a failure be waited on rather than reported: a handler that recognises an authorization refusal can park the call, have a human lend the missing scope, and let the continuation finish, without the route that refused knowing any of it happened.

The request takes everything .defer() declares except the schema (ttl, meta, callBinding, stepState) plus notify. The schema is declared where the handler is, so the resume door can read it back: .error(handler, { schema }) at route scope, or schema on an error hook.

Where it parks is the framework's decision, not the handler's. A handler runs outside the step tree and has no position of its own, so the executor resolves one from the step that actually failed:

  • A pipeline failure parks at that step's position, re-entrantly: the continuation is the failing step and everything after it, so nothing before it runs twice.
  • A pre-from chain failure parks at position 0 as an ADMISSION, because nothing in the body has run. Its continuation re-runs .authorize() and .input(), and only those; every other chain position keeps the answer it gives an ordinary resume.

A park at a position a deferral cannot be revived from (inside a .split() fan-out, or a .multicast() path or .dispatch() target) is refused with RC5051, exactly as a .defer() there is.

Warning

An admission park cannot reproduce a source parse

An admission park raised while a source-attached parser is still pending is refused with RC5051. A source parser arrives per message and is neither stored with the record nor re-derivable from the route, so the continuation would resume against an unparsed body. Sources that attach one are the file and stream shapes (json, csv, xml, jsonl, html, mail, carddav); http, direct and mcp attach none, so an identity-bearing transport is unaffected.

The schema is declared on the handler and validated at resume. A route-scope .error(handler, { schema }) and an error hook's schema declare what a resume payload must satisfy when that handler parks. The schema is folded into the continuation descriptor and rendered onto the acknowledgment, and at resume the door reads it back live off the route and validates the payload against it: a payload it refuses is RC5049 in the ingress route, leaving the deferral resumable, and a schema that changed or disappeared under a parked exchange refuses the resume with RC5048, exactly as a .defer() step's does. Handler code cannot be asked for a schema, which is why recovery.defer() takes none and a step-scope .error() (which cannot park) refuses one with RC5003.

notify: telling someone, in the right order

A handler that notifies a human and then returns the directive has told someone about a park the framework may still refuse. The site is resolved when the executor receives the directive, which is after the handler's side effects, so the recipient would hold a correctly signed token for a record that will never exist.

So the notification rides the directive and the framework owns the order: resolve the site (a refusal ends here, and nothing was sent), write the record, await notify with the same acknowledgment the caller receives, and only then emit route:exchange:deferred.

The event is last because it is the claim that an exchange IS parked. A notify that throws denies the record and fails the run with RC5067, so firing it earlier would give one exchange both route:exchange:deferred and route:exchange:failed, and a subscriber would be looking at a park that was already dead. An exchange gets exactly one terminal event.

notify tells someone; it must not answer on their behalf. forward awaits the route it calls, so a hook that forwards to a resume door resumes the parked exchange before notify returns, and therefore before route:exchange:deferred fires. A subscriber then sees route:exchange:resumed for a park it was never told about, and the caller is handed an acknowledgment whose token is already spent. Neither is worth designing around, because a hook that resumes the deferral it is announcing has not notified anyone: it has done the work, and the route should simply not have parked. Put the decision in the handler, where returning undefined declines to park at all.

export const stepUp = definePlugin({
  id: 'acme.step-up',
  hooks: {
    error: {
      id: 'parkForStepUp',
      phase: 'mutate',
      mayDefer: true,
      schema: stepUpDecision,
      run(error, exchange, info) {
        const refusal = insufficientAuthorityOf(error)
        if (!refusal || !refusal.effective) return undefined // not mine, next hook
        // Execution two already carries what a human lent; parking it again
        // would ask for the same scope a second time.
        if (info.execution === 2) return undefined
        return recovery.defer({
          ttl: '4h',
          meta: { scopes: refusal.scopes },
          notify: (ack) =>
            info.forward(sendStepUpMail, { token: ack.token, scopes: refusal.scopes }),
        })
      },
    },
  },
})

Two properties of that example are worth copying deliberately.

Forward, do not send. The handler does not render or send anything itself: it forwards to a route the application owns. forward carries the parked exchange's principal by reference, so the notifying route runs under the same frozen authority the parked work did; copying the principal instead would silently drop its brand. The send is then a route like any other, which means it is retryable, observable and testable without the handler.

The event carries no token. route:exchange:deferred deliberately carries deferralId, position and expiresAt and nothing else, so an event()-sourced route can announce that something is waiting but cannot build the resume link. The directive's own notify is the only thing handed the acknowledgment.

notify is bounded by the deferring route's own abort signal, widened by an enclosing .timeout(), because it is awaited inside the executor with a network call in it: an unsettled hook holds the step, which holds drain(), and its latency sits in front of the caller's acknowledgment. A throw and an abort are treated alike: the record is denied claim-first, so a token that did go out reads RC5050 rather than reviving work whose caller was told it failed, and the run fails with RC5067 carrying the hook's own failure as the cause.

Note

Two hooks, two orderings, two names

notify commits AFTER the record and BEFORE the deferred event. The announce callback on an aside deferral commits BEFORE its write, deliberately: an aside deferral carries no expiry, so a crash between the two must leave a reference to release rather than a record nothing points at. One hands an id to an in-process caller, the other hands a token to a person, and in both the commit ordering IS the safety property. They do not share a word for that reason.

The accepted residue is stated rather than hidden: a crash between the write and the notification leaves a record nobody was told about, which the record's ttl retires. The reverse order leaves a dead link in a human's inbox, which nothing retires.

A step-scope .error() wrapper cannot park and refuses recovery.defer with RC5051. Positions are assigned to the entries of the route's step array, which is the outermost wrapper of a stack, so a wrapper inside one has no position to name. Park from the route-scope handler or from an error slot hook; both see the same failure.

Note about tap errors: Tap operations emit errors to the route error handler via events. The main exchange continues (tap is fire-and-forget), but the error is observable for logging and monitoring.

Step scope (after .from())

.error() is dual-mode. Chained AFTER .from() it becomes a wrapper around the immediately next step instead of a route-level catch-all. On wrapped-step success the pipeline continues unchanged. On wrapped-step failure the handler runs, its return value replaces exchange.body, and the pipeline continues with the next step. Subsequent steps see the recovery as if nothing went wrong.

// Recover from one flaky call, keep processing
craft()
  .id('resilient-pipeline')
  .from(timer({ interval: 60_000 }))
  .transform(prepareRequest)
  .error((err) => ({ fallback: true, reason: String(err) }))
  .to(http({ url: 'https://flaky.api/endpoint' }))
  .to(database())

The handler signature is identical in both positions: (error, exchange, forward) => unknown | Promise<unknown>.

Cascade rule. When a step-scope handler itself throws, the wrapper rethrows. The route-scope handler (when set) catches it; otherwise the default error path fires (route:error, context:error, route:exchange:failed). The route is NOT stopped.

craft()
  .id('with-safety-net')
  .error((err, ex, forward) => forward('errors.catchall', ex.body))  // route scope
  .from(timer({ interval: 60_000 }))
  .transform(prepareRequest)
  .error((err) => ({ fallback: true }))                              // step scope
  .to(http({ url: 'https://flaky.api/endpoint' }))
  .to(database())

The step-scope handler recovers http failures silently. If it ever throws, the route-scope handler takes over and forwards to errors.catchall.

Stacking. Multiple wrappers stack outside-in in declaration order. The first-declared wrapper is the outermost. (Until a second public wrapper ships, this only matters when manually composing wrappers in tests.)

Scope only the next step. A wrapper attaches to exactly one step. .error(h).transform(a).transform(b) does NOT cover b (or to() after it); only a. Add another .error(...) before each step you want to wrap.

For the architectural pattern wrappers follow, see .standards/resilience-wrappers.md.

Note about direct destinations: Direct destinations with their own routes have their own error handlers. Errors in direct destinations are handled by their route's error handler, not the calling route.