Routing, extractors, state, error mapping and middleware for axum 0.8 on tower 0.5 and tower-http 0.6.
Contents
- Cargo setup
- Routing
- Extractors and their order
- State
- Custom extractors (0.8 style)
- Error type and IntoResponse
- Rejections: making extractor errors JSON
- Middleware with tower-http
- Layer ordering
- Concurrency limits and load shedding
- Body size limits
- Writing middleware with from_fn
- Health and readiness
- Serving: listeners, ConnectInfo, nesting
- Streaming and WebSockets
Cargo setup
[dependencies]
axum = { version = "0.8", features = ["macros"] } # "macros" for #[debug_handler]; default features include json, query, tokio, http1
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time"] }
tower = { version = "0.5", features = ["util", "limit", "load-shed", "timeout"] }
tower-http = { version = "0.6", features = ["trace", "timeout", "cors", "compression-gzip", "limit",
"request-id", "catch-panic", "sensitive-headers"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tracing = "0.1"Only enable the tower-http features you use; each is a separate module (tower_http::cors, ::timeout, ...).
Routing
use axum::{Router, routing::{get, post}};
fn api_router() -> Router<AppStateRef> {
Router::new()
.route("/orders", get(list_orders).post(create_order)) // several methods, one path
.route("/orders/{id}", get(get_order).delete(delete_order)) // capture
.route("/files/{*path}", get(serve_file)) // wildcard (rest of path)
.route("/users/{user_id}/orders/{order_id}", get(user_order)) // several captures
}
fn app(state: AppStateRef) -> Router {
Router::new()
.nest("/api/v1", api_router()) // prefix
.route("/healthz", get(|| async { "ok" }))
.fallback(not_found) // 404 for everything else
.with_state(state)
}- axum 0.8 path syntax:
{name}captures one segment,{*name}the rest."/:id"and"/*rest"panic when the router is built: run the app (or a router test) once after upgrading. Literal braces:{{/}}. - Overlapping routes (same path registered twice) panic at startup; that is a feature: build the router in a unit test so CI catches it.
Router<S>means "needs state S";.with_state(s)turns it intoRouter<()>, the only typeaxum::serveaccepts. Error "expectedRouter, foundRouter<Arc<AppState>>" = missingwith_state.- Handlers are
async fns whose arguments implementFromRequestParts/FromRequestand whose return type implementsIntoResponse. Closures work too:get(|| async { "ok" }).
Extractors and their order
| Extractor | Gets | Rejection status |
|---|---|---|
Path<T> (Path<u64>, Path<(u64, String)>, Path<Params>) | captures | 400 (bad parse) |
Query<T> | query string | 400 |
State<S> | app state | compile-time checked |
HeaderMap, TypedHeader<T> (axum-extra) | headers | - / 400 |
Extension<T> | value inserted by a layer | 500 if missing |
ConnectInfo<SocketAddr> | peer address | needs into_make_service_with_connect_info |
Json<T> | JSON body | 400 syntax, 415 content-type, 422 data |
Form<T>, Bytes, String, Multipart | body | 400/415/413 |
Request | the whole request | consumes everything |
Body extractors (FromRequest) consume the request and must be the last argument. Putting Json<T>
before State is the most common cause of the trait bound ... Handler<_, _> is not satisfied. Add
#[axum::debug_handler] to the handler to get a precise error.
use axum::{Json, extract::{Path, Query, State}, http::StatusCode};
#[derive(serde::Deserialize)]
struct Paging { limit: Option<u32>, cursor: Option<String> }
#[axum::debug_handler]
async fn create_order(
State(state): State<AppStateRef>,
Path(tenant): Path<String>,
Query(paging): Query<Paging>,
Json(input): Json<NewOrder>, // last
) -> Result<(StatusCode, Json<Order>), AppError> {
let order = state.repo.create(&tenant, input).await?;
let _ = paging;
Ok((StatusCode::CREATED, Json(order)))
}Option<Query<T>>-style optional extractors changed in 0.8: Option<T> now requires T: OptionalFromRequestParts and a malformed value is a rejection instead of None. For optional query fields,
prefer Query<T> with Option<_> fields as above.
State
use std::sync::Arc;
pub struct AppState {
pub db: sqlx::PgPool,
pub http: reqwest::Client,
pub config: Config,
}
pub type AppStateRef = Arc<AppState>;- One
Arc<AppState>: cloning the state per request is one atomic increment. StatevsExtension:Stateis type-checked at compile time;Extensionfails at runtime with 500 if the layer was not added. PreferState.- Substates: derive
FromRef(featuremacros) to let handlers extract a part of the state.
use axum::extract::FromRef;
#[derive(Clone, FromRef)]
struct AppState2 {
db: sqlx::PgPool,
http: reqwest::Client,
}
async fn ping_db(axum::extract::State(db): axum::extract::State<sqlx::PgPool>) -> &'static str {
let _ = db;
"ok"
}With FromRef the state struct itself must be Clone (each field is cheap to clone here).
Custom extractors (0.8 style)
axum 0.8 uses native async fn in traits: no #[async_trait] on the impl.
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
pub struct ApiKey(pub String);
impl<S: Send + Sync> FromRequestParts<S> for ApiKey {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(|v| ApiKey(v.to_owned()))
.ok_or((StatusCode::UNAUTHORIZED, "missing x-api-key"))
}
}To read app state inside an extractor, bound S with AppStateRef: FromRef<S> and call
AppStateRef::from_ref(state).
Error type and IntoResponse
One error enum per service (or per bounded context), mapped once:
use axum::{Json, http::StatusCode, response::{IntoResponse, Response}};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0} not found")]
NotFound(&'static str),
#[error("validation failed: {0}")]
Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("upstream timed out")]
UpstreamTimeout,
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
Upstream(#[from] reqwest::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::UpstreamTimeout => (StatusCode::GATEWAY_TIMEOUT, "upstream_timeout"),
AppError::Upstream(e) if e.is_timeout() => (StatusCode::GATEWAY_TIMEOUT, "upstream_timeout"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream"),
AppError::Database(sqlx::Error::RowNotFound) => (StatusCode::NOT_FOUND, "not_found"),
AppError::Database(sqlx::Error::PoolTimedOut) => (StatusCode::SERVICE_UNAVAILABLE, "busy"),
AppError::Database(_) | AppError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
};
let message = if status.is_server_error() {
tracing::error!(error = ?self, "request failed"); // full detail in logs only
"internal error".to_owned()
} else {
self.to_string()
};
(status, Json(json!({ "error": { "code": code, "message": message } }))).into_response()
}
}- Handlers return
Result<impl IntoResponse, AppError>and use?everywhere. - Log 5xx in one place (here) with the full chain; never return internal messages to clients.
anyhowis fine as a catch-all variant; keep variants that need specific status codes explicit.
Rejections: making extractor errors JSON
Built-in rejections return plain text. Wrap the extractor to keep one error format:
use axum::{extract::{FromRequest, rejection::JsonRejection}, response::{IntoResponse, Response}};
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct ApiJson<T>(pub T);
pub struct ApiError(JsonRejection);
impl From<JsonRejection> for ApiError {
fn from(r: JsonRejection) -> Self { ApiError(r) }
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.0.status();
(status, axum::Json(serde_json::json!({ "error": { "code": "bad_request", "message": self.0.body_text() } })))
.into_response()
}
}The FromRequest derive needs the macros feature. Implementing IntoResponse for ApiJson<T> as well
lets handlers return it.
Middleware with tower-http
use std::time::Duration;
use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
use tower::ServiceBuilder;
use tower_http::{
catch_panic::CatchPanicLayer,
compression::CompressionLayer,
cors::CorsLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
sensitive_headers::SetSensitiveRequestHeadersLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
fn with_middleware(router: axum::Router) -> axum::Router {
let x_request_id = HeaderName::from_static("x-request-id");
let cors = CorsLayer::new()
.allow_origin("https://app.example.com".parse::<HeaderValue>().expect("valid origin"))
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.max_age(Duration::from_secs(600));
router.layer(
ServiceBuilder::new()
.layer(SetSensitiveRequestHeadersLayer::new([header::AUTHORIZATION, header::COOKIE]))
.layer(SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid))
.layer(TraceLayer::new_for_http())
.layer(PropagateRequestIdLayer::new(x_request_id))
.layer(CatchPanicLayer::new())
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)))
.layer(cors)
.layer(CompressionLayer::new()),
)
}| Layer | Purpose | Notes |
|---|---|---|
TraceLayer::new_for_http() | span + events per request | customize with make_span_with, on_response, on_failure |
TimeoutLayer::new(d) | 408 after d | newer 0.6 releases add TimeoutLayer::with_status_code(status, d) and deprecate new; tower's own TimeoutLayer returns an error that needs HandleErrorLayer |
CorsLayer | CORS preflight + headers | CorsLayer::permissive() only for local dev; allow_credentials(true) cannot be combined with Any origins |
CompressionLayer | gzip/br/zstd/deflate response bodies | enable compression-gzip, compression-br, ... or compression-full |
RequestBodyLimitLayer::new(n) | 413 for bodies over n | put it outside TimeoutLayer (earlier in ServiceBuilder): TimeoutLayer needs a Default response body and the limit's body type is not, so the other order fails with ResponseBody<Body>: Default` is not satisfied |
CatchPanicLayer | panic -> 500 | log the panic; still fix it |
SetRequestIdLayer / PropagateRequestIdLayer | x-request-id in and out | put SetRequestId before TraceLayer so the span can record it |
SetSensitiveRequestHeadersLayer | redact secrets in traces | add before TraceLayer |
Router::layer only wraps routes added before the call; routes added after are not wrapped.
Router::route_layer wraps only matched routes (no effect on 404s), useful for auth.
Layer ordering
With ServiceBuilder, layers run top to bottom on the request (the first .layer is the outermost).
With chained Router::layer calls, the last call is the outermost. Prefer ServiceBuilder to keep the
order readable.
request -> SetSensitiveHeaders -> SetRequestId -> Trace -> PropagateRequestId -> CatchPanic -> Timeout
-> Cors -> Compression -> handlerPut tracing outside timeout so timed-out requests are traced with their 408; put request-id outside tracing;
put auth (route_layer) inside so 401s are traced.
Concurrency limits and load shedding
tower::limit::ConcurrencyLimitLayer::new(n): eachlayer()call creates its own semaphore.Router::layerapplies the layer to each route separately, so this becomes a per-route limit.tower::limit::GlobalConcurrencyLimitLayer::new(n): one shared semaphore for everything it wraps: a true global cap throughRouter::layer.- These limits make excess requests wait. To reject instead (fail fast under overload), add load shedding;
its error must be converted with
HandleErrorLayerbecause axum routes require infallible services:
use axum::{BoxError, error_handling::HandleErrorLayer, http::StatusCode};
use tower::{ServiceBuilder, limit::GlobalConcurrencyLimitLayer, load_shed::LoadShedLayer};
fn with_overload_protection(router: axum::Router) -> axum::Router {
router.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|_err: BoxError| async { StatusCode::SERVICE_UNAVAILABLE }))
.layer(LoadShedLayer::new()) // reject when the inner service is not ready
.layer(GlobalConcurrencyLimitLayer::new(512)), // at most 512 requests in handlers
)
}Bound upstream calls separately with a Semaphore in state; the router-level limit protects the process,
the per-upstream semaphore protects the dependency.
Body size limits
- axum's body extractors (
Json,Form,Bytes,String) apply a default 2 MB limit. Change it with.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))or turn it off withDefaultBodyLimit::disable(). tower_http::limit::RequestBodyLimitLayer::new(n)limits the body for everything including streaming handlers that readBodydirectly; it answers 413 whenContent-Lengthis too large up front.- Upload routes: disable the default for that route only and set a larger explicit limit.
use axum::{Router, extract::DefaultBodyLimit, routing::post};
use tower_http::limit::RequestBodyLimitLayer;
fn upload_routes() -> Router<AppStateRef> {
Router::new()
.route("/uploads", post(upload))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(250 * 1024 * 1024))
}Writing middleware with from_fn
use axum::{extract::{Request, State}, http::StatusCode, middleware::{self, Next}, response::Response};
async fn require_api_key(
State(state): State<AppStateRef>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let ok = req
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.is_some_and(|k| state.config.api_keys.iter().any(|known| known == k));
if !ok {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
}
fn protected(state: AppStateRef) -> axum::Router<AppStateRef> {
axum::Router::new()
.route("/admin/stats", axum::routing::get(stats))
.route_layer(middleware::from_fn_with_state(state, require_api_key))
}from_fn middleware: extractors first, Request second to last, Next last. Values for handlers go into
req.extensions_mut().insert(..) and come out with Extension<T>. Compare secrets in constant time
(subtle crate) in real code.
Health and readiness
| Endpoint | Answers | Checks | Used by |
|---|---|---|---|
/healthz (liveness) | "the process is not wedged" | nothing external; returns 200 | restart policy |
/readyz (readiness) | "send me traffic" | shutdown flag, DB ping with short timeout, critical caches warmed | load balancer / k8s endpoints |
/startupz (optional) | "initialization done" | migrations ran, config loaded | k8s startupProbe |
use std::{sync::atomic::Ordering, time::Duration};
use axum::{extract::State, http::StatusCode};
async fn readyz(State(state): State<AppStateRef>) -> StatusCode {
if state.shutting_down.load(Ordering::Relaxed) {
return StatusCode::SERVICE_UNAVAILABLE;
}
let ping = sqlx::query("SELECT 1").execute(&state.db);
match tokio::time::timeout(Duration::from_millis(500), ping).await {
Ok(Ok(_)) => StatusCode::OK,
_ => StatusCode::SERVICE_UNAVAILABLE,
}
}Never make liveness depend on the database: a DB outage would restart every pod and turn a partial outage into a full one.
Serving: listeners, ConnectInfo, nesting
use std::net::SocketAddr;
use axum::extract::ConnectInfo;
async fn whoami(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
addr.to_string()
}
async fn run(app: axum::Router) -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; })
.await?;
Ok(())
}axum::serveis a simple HTTP/1 + HTTP/2 server on hyper 1 (http1/http2features). For TLS termination use a proxy/ingress,axum-serverwith rustls, or a hyper-util connection loop.- axum 0.8
serveaccepts any type implementingaxum::serve::Listener(e.g.tokio::net::UnixListener). - Behind a proxy the peer address is the proxy: read
X-Forwarded-Foronly from trusted proxies.
Streaming and WebSockets
- Server-sent events:
axum::response::sse::{Sse, Event, KeepAlive}with aStreamofResult<Event, Infallible>; end the stream on shutdown (futures::StreamExt::take_until(stream, token.cancelled_owned())) or graceful shutdown waits for every open SSE connection. - WebSockets: feature
ws,WebSocketUpgradeextractor,ws.on_upgrade(|socket| handle(socket, state)). In 0.8Message::TextcarriesUtf8BytesandMessage::BinarycarriesBytes: use.as_str()/.into()conversions instead ofString/Vec<u8>. - Split a socket into sender/receiver (
socket.split()viafutures::StreamExt) and run them as two tasks in aJoinSetso either side ending aborts the other.
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.