Advanced

Pre-from Filter Chain

How .authorize(), .input(), .cache(), .error(), .throttle(), .retry(), .timeout(), .circuitBreaker(), and .concurrency() compose around your route.

Routecraft runs a fixed ordered chain of framework filters around every exchange before and after your user pipeline. The chain order is the framework's call -- the order you happen to type .authorize(), .input(), or .cache() on the builder does not change runtime behaviour. This is the same idea as Spring's FilterChainProxy or ASP.NET middleware: the framework picks the order; you opt in by declaring which filters you want.

The chain

Outside in (position 1 wraps everything below):

#FilterStatusOpts in viaThrows on rejectionReads / produces
1errorshipped.error(handler)-catches throws from everything below
2authorize (stacks)shipped.authorize({ roles, scopes, predicate })RC5012 / RC5015 / RC5023 / RC5020; delegation RC5034-RC5038principal on exchange.headers
3parseshippedsource adapter (HTTP, mail, CSV, ...)RC5016raw body bytes → typed body
4inputshipped.input(schema)RC5002typed body / headers
5throttleshipped.throttle({ rate, per, mode })RC5013 (mode: 'reject' only; default 'delay' paces)rate limit on the route (delay or reject)
6circuitBreakershipped.circuitBreaker({...})RC5025 (fast-fail when open)failure stats; fast-fails when open
7retryshipped.retry({...})final attempt's throwre-runs everything below on failure
8timeoutshipped.timeout(ms)RC5011per-attempt deadline
8.5concurrencyshipped.concurrency({ max })RC5026 (mode: 'reject' or full maxQueue; default 'queue' paces)bulkhead; bounds simultaneous in-flight (innermost resilience, so a slot is held per attempt)
9cacheCheckshipped.cache({...})RC5028 / RC5029validated body → cache key
-your pipeline-.transform(), .to(), .process(), ...-the work
10cacheStoreshipped.cache({...})swallows (cache:failed phase:"set")terminal body, written best-effort
Note

Position #4 (`input`) is a real chain step

.input() validation runs inside the chain at position #4: after auth and parse, before any resilience wrapper, cacheCheck, or user step. When the source attaches a parser, the validator runs inside the parse step (input validates the parsed body); otherwise it runs as a standalone synthetic input step. Either way a failure throws RC5002 through the chain's catch boundary, so .error() can observe and recover it exactly like an authorize or parse rejection. An unrecovered failure takes the normal error path (route:error, context:error, exchange:failed); it is not a drop.

What this means in practice

The chain runs in this order regardless of how you typed it

These three routes behave identically:

craft()
  .id('list-employees')
  .authorize({ roles: ['hr'] })
  .input(schema)
  .cache({ ttl: 60_000 })
  .from(http({ path: '/employees' }))
  .enrich(loadEmployees)
  .to(noop())

craft()
  .id('list-employees')
  .cache({ ttl: 60_000 })
  .input(schema)
  .authorize({ roles: ['hr'] })
  .from(http({ path: '/employees' }))
  .enrich(loadEmployees)
  .to(noop())

craft()
  .id('list-employees')
  .input(schema)
  .cache({ ttl: 60_000 })
  .authorize({ roles: ['hr'] })
  .from(http({ path: '/employees' }))
  .enrich(loadEmployees)
  .to(noop())

All three run errorauthorizeparseinputcacheCheckenrichtocacheStore. The DSL is declarative; you state which filters apply, not what order they run in.

Each filter throws on rejection; .error() decides what to recover

Filters 2-9 propagate failures upward by throwing. .error() is the outermost catch:

.error((err) => {
  // Deterministic rejections: re-throw so the source can translate
  // (e.g. HTTP returns 401, 403, or 400). Do not collapse the authorize
  // codes -- each one tells the client something different (see the
  // authorize() reference).
  if ([
    'RC5012', 'RC5015', 'RC5023', 'RC5020',
    'RC5034', 'RC5035', 'RC5036', 'RC5037', 'RC5038',
    'RC5002', 'RC5016',
  ].includes(err.rc)) throw err

  // Backpressure: re-throw so the caller sees it.
  if (err.rc === 'RC5013') throw err

  // Operational failures: recover with a fallback.
  if (err.rc === 'RC5011') return { fallback: 'timeout', data: stale }
  if (err.rc === 'RC5028') return { fallback: 'cache-down', data: stale }

  throw err
})

Without .error(), every throw goes to the route's default error path (route:<id>:error + context:error + exchange:failed). The route is not stopped -- the next exchange processes normally.

Why this order

Top half (1-4): deterministic gates

These are guards, not work. They're cheap, deterministic, and run once per request. Retrying them is pointless.

  • error outermost. Conceptually filter #1: its try/catch wraps the rest. Same shape as Spring's ExceptionTranslationFilter.
  • authorize before parse. Authorize reads the principal from headers; it doesn't need a parsed body. Running it first means an unauthenticated caller gets a clean 401 / 403 without the framework leaking schema information via a 400.
  • parse before input. Input validates the parsed shape, not raw bytes.
  • input before resilience wrappers. A request that fails schema is never going to succeed on retry. Reject early.

Middle (5-8.5): resilience wrappers

These DO retry / time out / fail fast. Standard outside-in following Resilience4J conventions.

  • throttle outside circuitBreaker. A throttled request shouldn't count as a breaker failure (the inner operation didn't even run).
  • circuitBreaker outside retry. When the breaker is open, fast-fail. Retries happen within one breaker call.
  • retry outside timeout. Each retry attempt gets its own deadline; per-attempt timeout is more useful than a shared budget.
  • concurrency innermost (inside timeout). A bulkhead slot is held only for the duration of one attempt: it is acquired at the start of each attempt and released the moment the attempt settles, so a retry backoff sleep holds no slot. An outer .retry() can also re-acquire a slot after a reject-mode RC5026 ejection. Contrast .throttle() (#5, outermost): a throttle rejection is outside retry and can only be caught by .error(), never retried.

Bottom (9-10): cache

Innermost. The pipeline's surface.

  • cacheCheck just above the pipeline. A hit short-circuits the pipeline without triggering retry / breaker / timeout (a hit is a successful zero-cost call from those layers' perspective).
  • cacheStore just below the pipeline. Runs only on miss-success. Cache write errors are swallowed (the result is already computed); they emit cache:failed phase:"set" for observability but don't fail the exchange.

Combined scenarios

Authorize fails

error
  └─ authorize throws RC5012  (no principal) or RC5015 (forbidden)
       └─ everything below is skipped

.error() catches. If your handler re-throws auth errors (the default for most apps), the source translates: HTTP returns 401 / 403, MCP returns an auth error.

Cache hit

error
  └─ authorize  PASS
       └─ parse  PASS
            └─ input  PASS
                 └─ cacheCheck  HIT  → cached body returned, pipeline skipped

The pipeline (including cacheStore) never runs. Filters 2-4 still ran, so an unauthorized caller never sees a hit.

Pipeline throws

error
  └─ authorize  PASS
       └─ parse  PASS
            └─ input  PASS
                 └─ cacheCheck  MISS
                      └─ pipeline  THROWS
                           └─ cacheStore  SKIPPED  (only runs on success)

The throw propagates up through cacheCheck (already passed; just re-throws), out to .error(). Nothing is cached. Next request with the same body re-runs the pipeline.

Retry outside timeout

With route-scope .retry() and .timeout() declared on the route:

error
  └─ authorize  PASS
       └─ parse  PASS
            └─ input  PASS
                 └─ retry  attempt 1
                      └─ timeout  hits 5s deadline → throws RC5011
                 ←  retry catches RC5011, attempt 2
                 └─ timeout  pipeline returns in 800ms → SUCCESS
                      └─ cacheStore  writes result

Per-attempt deadlines. Retry sees individual failures and decides whether to re-attempt.

What the chain commits the framework to

  • No reorder API. You opt filters in by declaring them; the order is the framework's call. If a future use case really needs a different order, it's an explicit RFC, not a per-route knob.
  • All wrappers throw on rejection. .error() is the universal catch; recovery is opt-in per RC code in the handler.
  • Deterministic gates above resilience wrappers. Auth, parse, input run once; they're not retried.
  • Cache is below resilience wrappers. A timeout / retry / breaker around cache means the framework retries pipeline calls that exceeded their deadline; cache hits short-circuit without triggering them.

Reference

  • This page is the user-facing contract for the chain. Implementation notes for contributors (how each position maps onto RouteDefinition and the pipeline executor) live at .standards/pre-from-filter-chain.md.
  • Operation reference pages link back here from their "where this slots into the chain" section.
  • The step-scope wrapper pattern (for .error() / .cache() applied after .from() to wrap a single step) is documented separately at .standards/resilience-wrappers.md.
Previous
Error Handling