The Request Lifecycle

The Request Lifecycle

On this page

A request's journey is the classic MVC spine: the server → the HTTP kernel → global middleware → the router → route middleware → the controller → back out, with a seam at every arrow.

Knowing where each seam is tells you where to put things — and, when something goes wrong, where to look.

The whole journey

sequenceDiagram
    autonumber
    participant C as Client
    participant S as Server<br/>(hyper)
    participant K as Kernel
    participant G as Global<br/>middleware
    participant R as CompiledRouter
    participant M as Route<br/>middleware
    participant H as Handler

    C->>S: TCP + HTTP request
    S->>S: read_body(limit)
    Note over S: over the limit → 413,<br/>body never fully read
    S->>K: Request (body buffered)

    K->>G: pipeline.run(request)
    G->>R: next.run(request)

    R->>R: match path, in declaration order
    Note over R: no path match → 404<br/>path but not method → 405 + Allow
    R->>R: insert route params + MatchedRoute

    R->>M: pipeline.run(request)
    M->>H: next.run(request)

    H->>H: extractors run (FromRequest)
    Note over H: an extractor may fail →<br/>422 / 400 / 401 straight back
    H-->>M: impl IntoResponse
    M-->>R: Response
    R-->>G: Response
    G-->>K: Response

    K->>K: RenderedError in extensions?
    Note over K: yes → ExceptionRenderer,<br/>carrying over Allow / Retry-After
    K-->>S: Response (body streams)
    S-->>C: HTTP response

The bootstrap

Before any of that, the application is assembled once, in a function you write using the Rainier builder:

// src/bootstrap.rs
pub async fn boot(mode: Mode) -> Result<Arc<Application>> {
    Rainier::new(".")
        .configure(|c| config::configure(c, &Env::load_or_default(".env")))
        .with_views(Arc::new(TemplateEngine::new("resources/views")))
        .with_database(database)
        .with_provider(AppServiceProvider { mode, database })
        .with_middleware(kernel::register)
        .with_events(EventServiceProvider::register_listeners)
        .with_routes(|router| {
            routes::web::routes(router);
            routes::api::routes(router);
        })
        .boot()
        .await
}

The builder

Rainier::new(base_path) reads .env from that path if it exists and seeds the config with framework defaults. Everything after it is a builder method:

MethodWhat it does
.configure(|config|)adjust configuration
.with_events(|dispatcher|)register listeners
.with_middleware(|registry|)register global middleware
.with_routes(|router|)declare routes
.with_views(engine)override the view engine
.with_database(db)bind a Database you built
.with_databases(dbs)declare the connections and let the framework open them
.with_queue(manager)bind a QueueManager you built
.with_queues(conns)declare the connections and let the framework build them
.with_jobs(registry)declare the jobs a worker can run
.with_storage(storage)bind a Storage you built
.with_disks(disks)declare the disks and let the framework build them
.with_cache(manager)bind a CacheManager you built
.with_mailer(mailer)bind a Mailer
.with_provider(provider)register a service provider
.without_facades()do not install the process-global facade app
.without_tracing()do not install a tracing subscriber
.boot().awaitbuild it, returning Arc<Application>

What boot() does, in order

The order matters, and it is the order it is for reasons you will eventually run into:

flowchart TD
    A[install tracing] --> B[create Application<br/>with APP_ENV]
    B --> C[install the facade application]
    C --> D[bind Config, Env, Dispatcher, Views<br/>and any database / queue / mailer]
    D --> E[register service providers]
    E --> F[compile the router<br/>against the middleware registry]
    F --> G[build UrlGenerator from named routes]
    G --> H[build the Kernel over the shared router]
    H --> I[bind CompiledRouter, UrlGenerator,<br/>MiddlewareRegistry, Kernel]
    I --> J[Application::boot →<br/>every provider's boot phase]

    style C fill:#553,stroke:#aa8,color:#fff
    style F fill:#535,stroke:#a8a,color:#fff

Facades are installed third, not last. A provider's boot, and a middleware built from the container while the router compiles, both legitimately reach for a facade — and both happen inside this call. Installing the global last would make them panic.

The router compiles before providers boot but after they register. That is what lets a route's middleware resolve a service that a provider bound.

Compiling at boot is deliberate. A route naming middleware the registry does not know, or two routes sharing a name, is a boot failure — not a surprise on the first request that happens to hit it:

Error: route `GET /admin`: could not build the `Authenticate` middleware:
nothing is bound for `app::auth::AuthManager<app::models::User>`

The router is compiled once and shared. CompiledRouter is not Clone — it owns each route's built pipeline — so the kernel and the container hold the same Arc. That is why route:list describes what is actually being served rather than a second compilation of it.

The kernel

rainier_server::Kernel is everything between "a request exists" and "a response exists", with the transport on one side and the router on the other.

It holds three things:

  • a pipeline of global middleware terminating in the router
  • an ExceptionRenderer
  • a debug flag, from app.debug
let kernel = app.resolve::<Kernel>()?;
let response = kernel.handle_request(request).await;

handle_request is what the server calls. It is handle plus one more step: if the response carries a RenderedError in its extensions, it is re-rendered through the ExceptionRenderer. That split exists because re-rendering needs the request, which the pipeline has already consumed.

Panics do not take down the process

A panic anywhere in a handler is caught and becomes a 500:

let outcome = std::panic::AssertUnwindSafe(self.pipeline.run(request));
match futures_catch_unwind(outcome).await { … }

Note futures_catch_unwind rather than std::panic::catch_unwind. The standard one wraps a closure, so a panic after the first .await would escape it. Rainier's wraps each poll, which is what makes the whole future covered.

One request bringing down the process — or, worse, poisoning a shared lock and taking every later request with it — is not an acceptable failure mode for a web framework.

Headers survive re-rendering

Re-rendering replaces the whole response, and some headers are part of the error's meaning rather than its body. A 405 is required to carry Allow; a throttled 429 is useless without Retry-After. carry_over_headers copies everything the renderer did not set itself, minus Content-Type and Content-Length — those belong to the new body.

Where the body is read

The server reads the request body into memory, refusing anything over server.max_body_bytes (2 MiB by default):

let bytes = read_body(incoming, limit).await?;

This is the single most visible departure from a streaming Rust framework, and it is what makes request.input("title") synchronous — exactly as $request->input() is in PHP. See Requests: why bodies are buffered.

Responses are the other way round: Body is either bytes or a stream, so a file download or an SSE endpoint does not have to fit in memory.

Serving it

Server::from_arc(kernel)
    .with_options(
        ServerOptions::default()
            .bind_to("127.0.0.1", 8000)?
            .max_body_bytes(2 * 1024 * 1024)
            .trust_forwarded_for(false),
    )
    .run()
    .await?;

Or just cargo run -- serve --port=3000, which is the same thing with the options read from config. See Console.

trust_forwarded_for is off by default. Turn it on only behind a proxy you control — otherwise any client can set X-Forwarded-For and forge its own IP, which matters because that is what the throttle middleware keys on.

run_until(shutdown) takes a watch::Receiver<bool> for graceful shutdown.

Outside HTTP

The console path skips all of this. Console::run_from_env(&app) parses argv, finds the command, and calls it with the same booted Application:

flowchart LR
    A[argv] --> B[Arguments::parse]
    B --> C{command<br/>known?}
    C -->|no| D[usage, exit 2]
    C -->|yes| E[Command::handle<br/>args, app]
    E --> F[exit code]

Same container, same providers, same config — only no request. That is why a job or a mailable must not reach for HTTP state: it may well be running in queue:work. See Console and Queues.

This page is the framework’s docs/lifecycle.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.