Skip to content

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.

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 Error

If 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.

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 ?FromIntoResponse, and it is entirely type-driven.

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.

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.

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 →

  1. What does #[from] generate on AppError::Database, and how does that connect to the behavior of the ? operator?
  2. A handler returns Result<Json<Note>, AppError>. What two conditions make that a valid axum response, and how does axum decide what to send?
  3. In the Database arm of into_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?
  4. axum middleware are added with .layer(...). If TraceLayer is added last, where does it sit relative to the handler, and in what order does it see the request and the response?
  5. The page claims the enum + exhaustive match protects “completeness.” What concretely happens at compile time if you add a new AppError variant but forget to handle it in into_response?
Show answers
  1. #[from] generates impl From<sqlx::Error> for AppError (the Database variant). The ? operator desugars to Err(e) => return Err(From::from(e)), so on a failed sqlx call, ? uses that From impl to convert sqlx::Error into AppError::Database automatically.
  2. Both the success type (Json<Note>) and the error type (AppError) must implement IntoResponse. axum inspects the Result: on Ok it renders the success value, on Err it calls the error’s into_response — either way it gets a Response.
  3. 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.
  4. 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.
  5. The match in into_response is 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.