Request lifecycle
What happens from cargo run -- serve to a response: the boot, every global layer in order, the route pipeline, the handler and the way back out.
On this page
Introduction
You can build a Navree app without knowing what happens between a request arriving and your handler running. But when a request gets a 401 you didn't expect, or you need to decide where a piece of middleware belongs, it helps to know the order of things.
This page follows an app from the command line to a running server, then follows one request through every layer of the pipeline to your handler and back out. Each stage links to the page that covers it in depth.
Booting the app
The command line
An app's main builds the root modules and calls run:
use navree::prelude::*;
fn main() -> Result<(), BoxError> {
crm_api::app(ConfigSources::defaults()).run()
}run starts a multi-threaded Tokio runtime, starts listening for Ctrl-C (and SIGTERM on Unix), and reads the first command-line argument:
| Command | What happens |
|---|---|
check [--graph] | Validates the module graph without constructing anything, then exits |
help or list | Prints every command, without booting |
A registered command (serve, migrate, queue:work, ...) | Boots the app, runs the command, shuts the app down |
| No command | Runs serve when a module registers it (HttpModule does), otherwise help |
An unknown command fails before anything boots.
Building the module graph
The kernel calls each module's register method once, starting from the root modules and following their imports. A module configured with for_root or from_config replaces plain imports of the same module. Then it validates the whole graph before constructing anything: every dependency must be visible, there must be no cycles, no singleton may depend on a request-scoped provider, and command names must be unique. Every problem found is reported together, each naming the module and the type. See modules.
Constructing the container
Next, every singleton is built, module by module in dependency order, so a module's imports are ready before the module itself. A provider's constructor receives its dependencies already built. ConfigModule loads the configuration first, DatabaseModule connects (and, with database.migrate_on_boot, migrates), and so on. Request-scoped and transient providers aren't built yet. See providers.
Lifecycle hooks
Then on_init runs for every provider that registered lifecycle hooks, followed by on_bootstrap for all of them. HttpModule uses its on_bootstrap to build the router once and check every route: a route without an access declaration, a route mounted twice, or a route that needs an authenticator the app doesn't have stops the boot here, before any port is bound. See lifecycle hooks.
Serving
Finally the command runs. serve builds the router, binds http.addr (127.0.0.1:8080 by default, or --addr HOST:PORT) and logs listening on http://127.0.0.1:8080. From here, every request goes through the pipeline below, until a shutdown signal starts the graceful shutdown.
The life of a request
navree-http assembles the pipeline in build_router. A request passes through these layers, outermost first, and the response passes back through them in reverse:
request
│
├─ request context request id, client IP, tracing span; request log and metrics on the way out
├─ CORS only when http.cors.allowed_origins is set
├─ panic catcher a panicking handler becomes a 500
├─ timeout http.timeout_secs (30 s); a route's own timeout can extend it
├─ security headers http.security_headers
├─ compression http.compression
├─ error rendering the NestJS error format, error mappers, 5xx logs
├─ maintenance mode 503 while the maintenance file exists
├─ per-IP rate limit http.per_ip_rate_limit requests per minute (600)
├─ body limit http.body_limit_bytes (2 MiB)
├─ Session slot navree-session, when the app uses it
├─ AuthContext slot the app's Authenticator first, then the slot's other layers
├─ Uow slot navree-db's unit of work, then the slot's other layers
├─ router 404 or 405 when no route matches
└─ route pipeline body length, authentication, throttle, guards, authorization,
route timeout, handler, audit actionThe fixed layers always run in this order. Modules add their own layers only in the three slots; see middleware.
Request context
The outermost layer sets up what every other layer relies on:
- The request id. An incoming
X-Request-Idis kept if it's 1 to 64 characters of letters, digits,-and_; anything else is replaced by a new id (a UUID v7 as 32 hex digits). The id is put back in the request headers and in the request extensions as aRequestId, and every response carries it inX-Request-Id. - The client IP. Taken from the connection, or from
X-Forwarded-For/X-Real-IPwhen the peer is a trusted proxy (http.trusted_proxies). It's stored as aClientIp; see requests. - The app. The
AppContextand a freshRequestScopego into the request extensions, so extractors can resolve providers andScoped<T>gets one instance per request. - A tracing span named
request, with the method, the path and the request id; the user id is added once the request is authenticated. Every log line written during the request carries these fields.
On the way out, this layer writes one log line per request (target navree::request, message request completed) with the method, the route template, the status, the latency in milliseconds, the request id, the user id and the IP. It also records the http_requests_total counter and the http_request_duration_seconds histogram, labelled by method, route and status, which ObserveModule exports to Prometheus.
CORS, panics and timeouts
- CORS answers preflight requests and adds the CORS headers for the origins listed in
http.cors.allowed_origins. With no origins listed (the default), there is no CORS layer. Because it sits outside the error layer, error responses get the CORS headers too. See CORS and security headers. - The panic catcher turns a panic anywhere further in into a 500
"Internal server error". The panic message is logged on targetnavree::error, never sent to the client. - The timeout gives every request
http.timeout_secs(30 seconds by default). A request that runs out of time gets a 503 with the message"The server did not finish the request in time.". A route with its own#[timeout]can be shorter or longer: a longer one moves this deadline.
Security headers and compression
- Security headers (
http.security_headers, on by default) addsX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer,Strict-Transport-Security: max-age=31536000; includeSubDomainsand aContent-Security-Policyofdefault-src 'none'; frame-ancestors 'none'; base-uri 'none'to every response that doesn't set them itself. - Compression (
http.compression, on by default) compresses responses with gzip or brotli when the client accepts them. Server-sent events (text/event-stream) aren't compressed.
Error rendering
This layer gives every error the same shape, the NestJS format:
{ "statusCode": 404, "message": "Not Found", "error": "Not Found" }Handlers, extractors, guards and inner layers return AppErrors; this layer turns them into that JSON. A 500 that carries a source error first goes through the error mappers modules contribute (DatabaseModule maps "record not found" to 404 and unique and foreign-key violations to 409, including those the unit of work hits when it commits). Server errors are logged on target navree::error, with the request id and the whole error chain; for a 500 the client only sees "Internal server error". Plain-text or empty error responses from Axum or Tower are rewritten into the same format, and Axum's 422 for well-formed JSON of the wrong shape becomes a 400. See errors.
Maintenance mode, rate limit, body limit
- Maintenance mode. While the file
http.maintenance_file(defaultstorage/framework/down) exists, every request gets a 503, except the paths inhttp.maintenance_except(default/health/live,/health/readyand/metrics). Thedownandupcommands create and remove the file; the layer looks at it at most once a second. See maintenance mode. - Per-IP rate limit. Each client IP may send
http.per_ip_rate_limitrequests per minute (600 by default). Over the limit, the answer is a 429 withRetry-After. See rate limiting, which also shows how to turn it off. - Body limit. Body extractors (
Json,Form,String,Bytes) read at mosthttp.body_limit_bytes(2 MiB by default); more is a 413.
Session, authentication and the unit of work
The three slots hold the layers that modules contribute, in this order:
Session. WithSessionModule, the session layer loads the session from its cookie and enforces CSRF, before authentication runs. Most API apps, the CRM included, don't use sessions, and this slot stays empty. See sessions and CSRF.AuthContext. Navree's authentication layer runs first in this slot. It calls the app'sdyn Authenticator(withAuthModule, the JWT strategy readsAuthorization: Bearer) and stores the result in the request as anAuthState: aPrincipalor a guest. A guest isn't an error here; routes decide later whether they need a user. But credentials that are present and invalid, such as an expired token, are rejected right here with the authenticator's 401, even on public routes and unknown paths. OtherAuthContextlayers run after it and can read theAuthState. See authentication.Uow. WithDatabaseModule, a unit of work is opened for the request, lazily, with the authenticated user as the actor and the request id. The rest of the request runs as a task on the app's tracker, so it finishes and commits even if the client disconnects. On the way back, the unit of work commits when the status is below 400 and rolls back otherwise. See the unit of work.
Routing
The router matches the path and the method against every mounted route. A path no route matches gets the single 404 fallback; a known path with the wrong method gets a 405 with an Allow header. The global layers wrap the fallback too, so a 404 is still logged, rate-limited and rendered in the error format. Axum answers HEAD for GET routes. See routing.
The route pipeline
Each matched route runs its own small pipeline before the handler. It puts the route's RouteMeta (path, name, access declaration, limits) into the request extensions, then checks, in order:
Body length
A
Content-Lengthlarger than the route's body limit (its own, orhttp.body_limit_bytes) is a 413 with the message"The request body must not exceed N bytes.", before any byte of the body is read.Authentication
A route that isn't
#[public]needs a principal. Without one, the request gets a 401"Unauthorized", and nothing below runs.Throttle
A route with
#[throttle(limit = 10, per = "1m")]counts requests per user (per client IP for guests). Over the limit: 429 withRetry-After.Guards
The route's
#[guard]s run in order (the impl block's first). The first guard that returns an error ends the request with that error.Authorization
A route that declares
#[roles],#[permissions]or#[can]is checked by the app'sdyn RouteAuthorizer(withAccessModule). A refusal is a 403; a#[can(.., from = "id")]route whose record doesn't exist is a 404. See access control.The handler
The handler runs, within the route's
#[timeout]when it has one (503 when it runs out).After the handler
A route with
#[audit(action = "..")]writes that audit entry after a 2xx response. When the app has an audit sink, refusals along the way are recorded as security events too: throttling ashttp.rate_limited, 403s from guards and handlers asaccess.denied.
The handler
Navree calls your handler with its arguments built by extractors, in the order you declare them; the extractor that reads the body comes last. An extractor that fails ends the request with its rejection, rendered in the error format: a path parameter that doesn't parse is a 400, JSON without a Content-Type: application/json header is a 415, a body that doesn't deserialize is a 400. See requests.
#[patch("/{id}")]
#[can(Update, Deal, from = "id")]
async fn update(
&self,
uow: Uow,
CurrentUser(user): CurrentUser,
deal: Authorized<Deal, Update>,
Valid(Json(input)): Valid<Json<UpdateDeal>>,
) -> Result<Json<DealResource>> {
Ok(Json(self.deals.update(&uow, user.id, deal, input).await?))
}This CRM handler receives the request's unit of work, the signed-in user loaded from the database, the deal the authorizer already loaded and checked, and the validated JSON body. By the time it runs, every check above has passed.
A handler returns anything that implements Axum's IntoResponse. With the prelude, Result<T> means Result<T, AppError>, so ? works on any error: errors without a specific mapping become a logged 500, and modules can map their own error types to specific statuses. See responses and errors.
The way back out
The response retraces the pipeline in reverse:
- The route pipeline writes the route's audit entry (on success) and records the route on the response, for the request log.
- The unit of work commits or rolls back. A failed commit replaces the response with a 500.
- The error layer renders any
AppErrorthe response carries, applies the error mappers, and logs server errors. - Compression, security headers and CORS headers are added.
- The request context layer sets
X-Request-Id, writes the request log line and records the metrics.
Testing the whole pipeline
TestClient from navree::testing sends requests through this same router in-process, with every layer above, so a test sees exactly what a client would: the request id, the error format, authentication and the unit of work. See HTTP tests.