Day 10 · Errors & middleware
Day 9 left ? and AppError::NotFound scattered through the
handlers without saying what they are. Today we make them real. The goal is a single error type
that knows how to become an HTTP response, so the happy path of every handler stays a straight line
and the error path stays correct — no match ladders, no status code typed twice, no database
internals leaking to clients. Then we wrap the whole app in a tracing layer and read config from the
environment.
The problem: one handler, many failures
Section titled “The problem: one handler, many failures”A single create-note handler can fail in genuinely different ways:
malformed JSON in the body → 400 Bad Request empty title (our rule) → 422 Unprocessable Entity id doesn't exist (on PUT) → 404 Not Found database is down / locked → 500 Internal Server ErrorIf each handler decides its own status codes inline, you’ll get them subtly wrong, and worse, you’ll be tempted to return the raw database error to the client — handing an attacker your schema. The fix is to funnel every failure through one type that owns the mapping.
AppError: a closed set of failures
Section titled “AppError: a closed set of failures”We model the failures as an enum — a closed set the compiler can check exhaustively — and let
thiserror derive the boilerplate (Display, the Error trait, and From conversions):
#[derive(Debug, thiserror::Error)]pub enum AppError { #[error("not found")] NotFound,
#[error("{0}")] Validation(String),
// #[from] generates `impl From<sqlx::Error> for AppError`, // which is what lets `?` convert a database error automatically. #[error("database error: {0}")] Database(#[from] sqlx::Error),}That #[from] is the hinge the whole ergonomic story turns on. Here’s why.
Under the hood — how ? becomes an HTTP response
Section titled “Under the hood — how ? becomes an HTTP response”The ? operator has a precise desugaring. value? means roughly:
match value { Ok(v) => v, Err(e) => return Err(From::from(e)), // ← note the From::from }So when a sqlx call returns Err(sqlx::Error) and you write ?, Rust calls From::from to convert
it into the function’s declared error type — and #[from] is exactly the
From<sqlx::Error> for AppError impl that makes that conversion exist. One character, ?, and a DB
error becomes an
AppError::Database.
The second half is axum’s: a handler returning Result<T, E> is itself a valid response when both
T and E implement IntoResponse. axum picks the Ok arm or the Err arm and turns whichever
it got into a response. So we implement IntoResponse for AppError once:
impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()), AppError::Database(err) => { tracing::error!(error = %err, "database error"); // log the detail (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()) // hide it } }; (status, Json(json!({ "error": message }))).into_response() }}Put together: query(...).await? in a handler will, on failure, convert the error via From, return
it, and let axum render it through into_response. The handler body never mentions a status code. The
chain is ? → From → IntoResponse, and it is entirely type-driven.
Don’t leak the internals
Section titled “Don’t leak the internals”Look closely at the Database arm: it logs the real error with tracing::error!, but the client
only ever sees "internal server error". That asymmetry is deliberate. A raw database error can
contain table names, column names, even fragments of your query — a gift to an attacker. Verbose error
messages are a recognized weakness class (CWE-209, “error message containing sensitive
information”). The rule: log for yourself, return a generic message to the world. The enum
makes this easy to get right once and reuse everywhere.
Middleware: wrapping every request with tracing
Section titled “Middleware: wrapping every request with tracing”A middleware is code that wraps every request — logging, auth, timeouts — without touching any
handler. In axum (built on tower) middleware are layers, added with .layer(...). We add
tower_http’s TraceLayer, which emits one span per request with method, path, status, and latency:
use tower_http::trace::TraceLayer;
Router::new() .route("/notes", get(list_notes).post(create_note)) .layer(TraceLayer::new_for_http()) // wraps everything above .with_state(state)Layer order is “wrapping” order: the layer added last is the outermost — it sees the request first and the response last. A request flows inward through the layers to the handler, and the response flows back outward:
request ─► TraceLayer ─► router ─► handler response ◄─ TraceLayer ◄────────────── (status, latency recorded here)To make the logs actually appear, main initializes a subscriber with an EnvFilter, so RUST_LOG
controls verbosity at runtime:
tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env() .unwrap_or_else(|_| "apilite=debug,tower_http=info,info".into()), ) .init();RUST_LOG=apilite=debug cargo run turns on debug logs for our crate without drowning in dependency
noise — observability you can dial in without recompiling.
Config from the environment
Section titled “Config from the environment”The last piece of production-readiness is to stop hard-coding the bind address and database URL. apilite reads them from the environment with sensible defaults, so it runs with zero setup locally and is fully configurable in a container:
impl Config { pub fn from_env() -> Self { Self { bind_addr: std::env::var("APILITE_BIND_ADDR") .unwrap_or_else(|_| "127.0.0.1:3000".to_string()), database_url: std::env::var("DATABASE_URL") .unwrap_or_else(|_| "sqlite:apilite.db".to_string()), } }}One place owns configuration; the rest of the app just receives a Config. That’s the same instinct
as the single AppError — centralize the thing that’s easy to get inconsistently wrong.
The thread
Section titled “The thread”What does building this force you to understand — and what is Rust’s compiler protecting you from?
The error type is the clearest example yet. Because AppError is a closed enum and the match in
into_response must be exhaustive, the compiler guarantees every failure variant produces a
response — add a new variant and the build breaks until you map it to a status code. There is no path
where a request silently dies or an error escapes unhandled. The compiler isn’t protecting memory here
so much as protecting completeness: the same “make every case explicit” discipline that prevents
use-after-free also prevents the forgotten error branch.
Next: prove it all works with integration tests, then package it in a small Docker image. Day 11 · Tests & Docker →
Check your understanding
Section titled “Check your understanding”- What does
#[from]generate onAppError::Database, and how does that connect to the behavior of the?operator? - A handler returns
Result<Json<Note>, AppError>. What two conditions make that a valid axum response, and how does axum decide what to send? - In the
Databasearm ofinto_response, the real error is logged but the client gets"internal server error". Why is that split important, and what weakness class does it address? - axum middleware are added with
.layer(...). IfTraceLayeris added last, where does it sit relative to the handler, and in what order does it see the request and the response? - The page claims the
enum+ exhaustivematchprotects “completeness.” What concretely happens at compile time if you add a newAppErrorvariant but forget to handle it ininto_response?
Show answers
#[from]generatesimpl From<sqlx::Error> for AppError(theDatabasevariant). The?operator desugars toErr(e) => return Err(From::from(e)), so on a failed sqlx call,?uses thatFromimpl to convertsqlx::ErrorintoAppError::Databaseautomatically.- Both the success type (
Json<Note>) and the error type (AppError) must implementIntoResponse. axum inspects theResult: onOkit renders the success value, onErrit calls the error’sinto_response— either way it gets aResponse. - The real database error can expose schema details (table/column names, query fragments) — useful to an attacker. Logging it serves you (debugging) while the generic message protects the client boundary. It addresses CWE-209, sensitive information disclosure through error messages.
- Added last means outermost: it wraps the router and handler. It sees the request first (on the way in) and the response last (on the way out) — which is exactly where it records the final status and total latency.
- The
matchininto_responseis exhaustive, so an unhandled variant is a compile error (“non- exhaustive patterns”). The build fails until you map the new variant to a status code — there’s no way to ship a failure mode that silently has no response.