Beyond the defaults

Error handling

Catch pipeline errors and recover gracefully with .error().

By default, when a step throws an unhandled error, Routecraft logs it and emits route:error, context:error, and route:exchange:failed events, then moves on so the route keeps running and the next exchange is processed normally. .error() extends this behavior with a custom recovery handler.

Basic usage

Define .error() before .from(). When any step in the pipeline throws, the handler is invoked instead:

craft()
  .id('process-orders')
  .error((error, exchange) => {
    return { status: 'failed', reason: (error as Error).message }
  })
  .from(timer({ interval: 60_000 }))
  .transform(fetchOrders)
  .to(processOrder)

The handler's return value becomes the route's final exchange body. The pipeline does not resume after the handler runs.

Parameters

ParameterTypeDescription
errorunknownThe thrown error
exchangeExchangeThe exchange at the point of failure. Its headers include route id, correlation id, and operation type
forward(routeId, payload) => Promise<unknown>Send a payload to another capability via the direct adapter

The forward function

The third parameter, forward, sends a payload to another capability by route id and returns its result. It uses the direct adapter channel internally, so no extra transport or configuration is needed.

forward(routeId: string, payload: unknown): Promise<unknown>
ArgumentDescription
routeIdThe target capability's direct endpoint id (must match the target route's .id())
payloadAny value; it becomes the target capability's exchange body
returnsThe final exchange body produced by the target capability's pipeline

forward is async. The error handler waits for the target capability to finish processing and returns whatever that capability produces. This means you can use the target's result as the recovery value for the failed capability.

forward runs as the caller

A forwarded call carries the failing exchange's headers, so the target sees the same authenticated principal and the same correlation id as the capability that forwarded to it. A target declaring .authorize() therefore accepts a forward that the caller was itself authorized to make, and the whole hop stays on one trace.

This is unconditional and cannot escalate: it is the same identity the calling capability was already running under. A capability that needs to act as something else establishes that explicitly with .authenticate().

Two consequences worth knowing:

  • A target with a strict .input({ headers }) schema sees the caller's headers, so a schema that rejects unknown keys can refuse a forward.
  • Forwarding a principal whose expiry has passed surfaces the expiry error rather than a missing-principal RC5012.

The same applies to the forward handed to a .circuitBreaker() fallback and to client.forward() in an agent block resolver.

Example: delegate to a dedicated error capability

// capabilities/process-orders/route.ts
import { craft, timer } from '@routecraft/routecraft'

export default craft()
  .id('process-orders')
  .error(async (error, exchange, forward) => {
    // forward() returns what the error capability's pipeline produces
    const result = await forward('order-errors', {
      originalBody: exchange.body,
      reason: (error as Error).message,
      failedAt: exchange.headers['routecraft.operation'],
    })
    return result
  })
  .from(timer({ interval: 60_000 }))
  .transform(fetchOrders)
  .to(processOrder)
// capabilities/order-errors/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
import { z } from 'zod'

export default craft()
  .id('order-errors')
  .description('Receives failed order payloads for alerting')
  .input({ body: z.object({ reason: z.string() }).passthrough() })
  .from(direct())
  .transform((body) => ({ alerted: true, reason: body.reason }))
  .to(http({ url: 'https://alerts.example.com/orders' }))

In this example, forward('order-errors', ...) sends the failure payload to order-errors, waits for it to run its full pipeline (transform then HTTP call), and returns { alerted: true, reason: '...' } back to the error handler. That value becomes the final exchange body for process-orders.

When not to use forward

If you only need to log or return a static fallback, you do not need forward at all. Just return a value directly:

.error((error) => {
  return { status: 'failed', reason: (error as Error).message }
})

Step-scope handlers

.error() is dual-mode. Chained AFTER .from() it wraps the immediately next step instead of the whole route. 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.

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())

Reads as: "if http(...) throws, swallow it and continue to database with { fallback: true, reason: ... } as the body". Subsequent steps see the recovery as if the step had succeeded.

The handler signature is identical to the route-scope form: (error, exchange, forward) => unknown | Promise<unknown>.

Combined route + step handlers

Step handlers are local recovery; route handlers are the safety net. Use both:

craft()
  .id('with-safety-net')
  .error((err, ex, forward) => forward('catch-all-errors', 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 handler recovers http failures silently. If it ever throws, the route handler takes over and forwards to catch-all-errors.

Cascade rule

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

Scope only the next step

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

Drop or rethrow instead of recovering

A handler does not have to produce a body. Return one of the recovery directives to say what should happen to the exchange instead:

import { recovery } from '@routecraft/routecraft'

.error((error) =>
  isTransient(error) ? recovery.rethrow() : recovery.drop('poison message'),
)
  • recovery.drop(reason) discards the exchange, the way a .filter() does. route:exchange:dropped fires with your reason, and nothing downstream runs.
  • recovery.rethrow() declines to recover and propagates the original error, exactly as if the handler had thrown it.

Both work at route scope and at step scope.

When the error handler itself throws

If a route-scope .error() handler throws, the context takes over:

  1. The handler's error is logged
  2. route:error-handler:failed fires, so you can tell a handler failure from a step failure
  3. route:error and context:error fire, as on the default no-handler path
  4. route:exchange:failed fires with the handler's error
  5. The route stays alive and processes the next message normally

A step-scope handler that throws passes its error outward instead, as described in the cascade rule.

This means you always have a safety net. Even a broken error handler cannot crash the route.

Events

When .error() is defined, the handler lifecycle emits its own events:

EventWhen
route:error-handler:invokedError handler is called
route:error-handler:recoveredHandler returned successfully
route:error-handler:failedHandler itself threw

The event names are fixed; the route identity travels in the payload. Every payload carries routeId, exchangeId, correlationId, originalError, failedOperation, and scope ("route" or "step", plus stepLabel at step scope).

The two outcomes differ in what else fires alongside them:

  • Successful recovery: invoked and recovered fire, and at route scope route:error:caught fires as well. The default failure set (route:error, context:error, route:exchange:failed) does not fire, because the exchange was recovered.
  • Handler failure: invoked and failed fire, and then the error takes the normal failure path, so the full default set (route:error, context:error, route:exchange:failed) fires as well (see "When the error handler itself throws" above).

Subscribing to events

Use context.on() with the exact event name. The event bus rejects wildcard patterns such as route:* (the only catch-all is "*", which receives every event): since identity lives in the payload, subscribing to an exact name already observes every route, and forRoute(routeId, handler) narrows a subscription to one route:

import { ContextBuilder, forRoute } from '@routecraft/routecraft'

const { context } = await new ContextBuilder()
  .routes(myRoutes)
  .on('route:error-handler:invoked', ({ details }) => {
    console.log(
      `Error handler called on ${details.routeId}`,
      `failed at: ${details.failedOperation}`,
    )
  })
  .on('route:error-handler:recovered', forRoute('process-orders', ({ details }) => {
    console.log(`Recovered: ${details.routeId}`)
  }))
  .on('route:error-handler:failed', ({ details }) => {
    // The handler itself failed: alert
    alertOps(`Error handler crashed on ${details.routeId}`, details.originalError)
  })
  .build()

For a catch-all, subscribe to context:error. This fires for all unhandled errors and for handler failures:

context.on('context:error', ({ details }) => {
  console.error('Unhandled error:', details.error)
})

Events

Subscribe to error and exchange lifecycle events.

error

The full .error() signature at route scope and at step scope.

Errors

Every RC code a handler can receive, with what it means and how to fix it.

Previous
Composing capabilities