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):
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 error → authorize → parse → input →
cacheCheck → enrich → to → cacheStore. 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.
erroroutermost. Conceptually filter #1: its try/catch wraps the rest. Same shape as Spring'sExceptionTranslationFilter.authorizebeforeparse. Authorize reads the principal from headers; it doesn't need a parsed body. Running it first means an unauthenticated caller gets a clean401/403without the framework leaking schema information via a400.parsebeforeinput. Input validates the parsed shape, not raw bytes.inputbefore 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.
throttleoutsidecircuitBreaker. A throttled request shouldn't count as a breaker failure (the inner operation didn't even run).circuitBreakeroutsideretry. When the breaker is open, fast-fail. Retries happen within one breaker call.retryoutsidetimeout. Each retry attempt gets its own deadline; per-attempt timeout is more useful than a shared budget.concurrencyinnermost (insidetimeout). 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 aretrybackoff sleep holds no slot. An outer.retry()can also re-acquire a slot after areject-modeRC5026ejection. 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.
cacheCheckjust 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).cacheStorejust below the pipeline. Runs only on miss-success. Cache write errors are swallowed (the result is already computed); they emitcache: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
RouteDefinitionand 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.