Frontend Labs
⋯

47. A production HTTP server with Axum: limits, timeouts, tracing, shutdown

Implements: HTTPS Ch 19 (the Go version) and Go §62.

Axum is a thin layer over Hyper (the HTTP implementation) and Tower (a middleware abstraction: Service<Request> -> Future<Response>). Its main idea is extractors: a handler's parameter types say what it needs from the request, and Axum parses and validates them before the handler runs.

// src/bin/api.rs
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer, trace::TraceLayer};

#[derive(Clone, Default)]
struct AppState {                                   // cloned per request: keep it cheap (Arcs inside)
    users: Arc<RwLock<HashMap<u64, User>>>,
    next_id: Arc<AtomicU64>,
}

#[derive(Clone, Serialize)]
struct User { id: u64, name: String, email: String }

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]                       // reject {"name":..,"is_admin":true} (mass assignment)
struct NewUser { name: String, email: String }

/// One error type for the whole API, mapped to HTTP in exactly one place.
enum ApiError { NotFound, Invalid(String) }

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, msg) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
            ApiError::Invalid(m) => (StatusCode::UNPROCESSABLE_ENTITY, m),
        };
        (status, Json(serde_json::json!({ "error": msg }))).into_response()
    }
}

async fn healthz() -> &'static str { "ok" }

async fn create_user(State(st): State<AppState>, Json(body): Json<NewUser>) -> Result<(StatusCode, Json<User>), ApiError> {
    let name = body.name.trim();
    if name.is_empty() || name.len() > 100 { return Err(ApiError::Invalid("name must be 1-100 characters".into())); }
    if !body.email.contains('@') || body.email.len() > 254 { return Err(ApiError::Invalid("invalid email".into())); }
    let id = st.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let user = User { id, name: name.into(), email: body.email.to_lowercase() };
    st.users.write().unwrap().insert(id, user.clone());   // lock held briefly, never across .await
    Ok((StatusCode::CREATED, Json(user)))
}

async fn get_user(State(st): State<AppState>, Path(id): Path<u64>) -> Result<Json<User>, ApiError> {
    st.users.read().unwrap().get(&id).cloned().map(Json).ok_or(ApiError::NotFound)
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/healthz", get(healthz))
        .route("/users", post(create_user))
        .route("/users/{id}", get(get_user))               // axum 0.8 path syntax: {id}
        .layer(TraceLayer::new_for_http())                 // one span per request, with latency
        .layer(TimeoutLayer::with_status_code(StatusCode::SERVICE_UNAVAILABLE, Duration::from_secs(10)))
        .layer(RequestBodyLimitLayer::new(64 * 1024))      // 413 for bodies over 64 KiB
        .with_state(state)
}

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.expect("install Ctrl-C handler") };
    #[cfg(unix)]
    let term = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };
    #[cfg(not(unix))]
    let term = std::future::pending::<()>();
    tokio::select! { _ = ctrl_c => {}, _ = term => {} }
    tracing::info!("shutdown signal received, draining connections");
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    tracing_subscriber::fmt().with_target(false).init();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    tracing::info!("listening on {}", listener.local_addr()?);
    axum::serve(listener, app(AppState::default()))
        .with_graceful_shutdown(shutdown_signal())        // stop accepting, finish in-flight requests
        .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::{to_bytes, Body};
    use axum::http::Request;
    use tower::ServiceExt;                                // .oneshot(): call the Router without a socket

    async fn call(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
        let req = Request::builder().method(method).uri(uri)
            .header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
        let resp = app.clone().oneshot(req).await.unwrap();
        let status = resp.status();
        let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
        (status, String::from_utf8_lossy(&bytes).into_owned())
    }

    #[tokio::test]
    async fn user_lifecycle_and_limits() {
        let app = app(AppState::default());
        let (s, b) = call(&app, "POST", "/users", r#"{"name":"Ada","email":"ADA@x.io"}"#).await;
        assert_eq!(s, StatusCode::CREATED);
        assert!(b.contains(r#""email":"ada@x.io""#));
        assert_eq!(call(&app, "GET", "/users/1", "").await.0, StatusCode::OK);
        assert_eq!(call(&app, "GET", "/users/99", "").await.0, StatusCode::NOT_FOUND);
        assert_eq!(call(&app, "GET", "/users/abc", "").await.0, StatusCode::BAD_REQUEST);  // Path<u64> rejected
        assert_eq!(call(&app, "POST", "/users", r#"{"name":"x","email":"x@y","is_admin":true}"#).await.0,
                   StatusCode::UNPROCESSABLE_ENTITY);                                    // deny_unknown_fields
        assert_eq!(call(&app, "POST", "/users", r#"{"name":"","email":"a@b"}"#).await.0, StatusCode::UNPROCESSABLE_ENTITY);
        let huge = format!(r#"{{"name":"{}","email":"a@b"}}"#, "x".repeat(100_000));
        assert_eq!(call(&app, "POST", "/users", &huge).await.0, StatusCode::PAYLOAD_TOO_LARGE);
    }
}
cargo run --bin api &
curl -s localhost:3000/users -H 'content-type: application/json' -d '{"name":"Ada","email":"ada@example.com"}'
# {"id":1,"name":"Ada","email":"ada@example.com"}
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/users/42      # 404
kill -TERM %1                                                          # graceful drain, exit 0
cargo test --bin api

What each layer defends against, mapped to the HTTPS guide:

Control Threat Guide
RequestBodyLimitLayer memory exhaustion from huge bodies HTTPS Ch 12
TimeoutLayer slow handlers holding resources HTTPS Ch 19
deny_unknown_fields + validation mass assignment, bad input Sec Ch 45
typed Path<u64> injection through path parameters Sec Ch 42
graceful shutdown dropped in-flight requests during deploys OS Ch 82
TraceLayer being unable to tell what happened HTTPS Ch 17

One gap to know about: axum::serve does not expose a header read timeout. A Slowloris client that sends one header byte every few seconds is limited only by the OS and by TimeoutLayer, and TimeoutLayer starts counting only after the headers have been parsed. Production deployments either sit behind a proxy that enforces it (nginx, Envoy, or a cloud load balancer), or serve the Router with hyper-util's connection builder, which has header_read_timeout. Ch 50 shows that builder.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36