Error Handling

Rust has no exceptions. Functions that can fail return Result<T, E>, and the caller decides what to do with the error. This is a strength, but it means you need a deliberate strategy for how errors propagate through your web application and how they turn into HTTP responses.

The approach here uses two crates that serve different purposes. thiserror generates typed error enums for domain and library code, where callers need to match on specific failure modes. anyhow provides type-erased error propagation for application code, where you just want to bubble errors up with context.

thiserror for typed errors

thiserror is a derive macro that generates std::error::Error implementations for your error types. Use it when callers need to distinguish between different failure modes.

[dependencies]
thiserror = "2"

Define an error enum for a domain module:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("user not found: {0}")]
    NotFound(i64),

    #[error("email already registered: {0}")]
    DuplicateEmail(String),

    #[error("invalid email format: {0}")]
    InvalidEmail(String),
}

The #[error("...")] attribute generates the Display implementation. Field values are interpolated using the same syntax as format!.

Wrapping source errors with #[from]

#[from] generates a From<T> implementation and sets the error’s source() return value, so the ? operator converts the source error automatically:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum RepoError {
    #[error("database error")]
    Database(#[from] sqlx::Error),

    #[error("user not found: {0}")]
    NotFound(i64),

    #[error("email already registered: {0}")]
    DuplicateEmail(String),
}

A repository function can now use ? on SQLx calls and the error converts automatically:

async fn find_user(pool: &PgPool, id: i64) -> Result<User, RepoError> {
    let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_one(pool)
        .await?; // sqlx::Error -> RepoError::Database
    Ok(user)
}

Transparent forwarding

#[error(transparent)] delegates both Display and source() to the inner error. This is useful for catch-all variants:

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found: {0}")]
    NotFound(String),

    #[error(transparent)]
    Database(#[from] sqlx::Error),
}

When AppError::Database is displayed, it prints the SQLx error’s message directly rather than wrapping it.

When to use thiserror

Use thiserror in library crates, domain modules, and any code where the caller needs to match on the error variant to decide what to do. A repository crate that returns RepoError::NotFound lets the handler return a 404. A repository that returns anyhow::Error forces the handler to treat everything as a 500.

anyhow for application code

anyhow provides a single error type, anyhow::Error, that wraps any error implementing std::error::Error. It is designed for application code where you want to propagate errors with added context rather than define typed variants for every possible failure.

[dependencies]
anyhow = "1"

Adding context

The .context() and .with_context() methods attach human-readable messages to errors as they propagate up the call stack:

use anyhow::{Context, Result};

async fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {path}"))?;

    let config: Config = toml::from_str(&content)
        .context("failed to parse config")?;

    Ok(config)
}

Result here is anyhow::Result<T>, an alias for Result<T, anyhow::Error>. The ? operator converts any error into anyhow::Error and the .context() call wraps it with an additional message. When logged, the full chain is visible: “failed to parse config: expected =, found [ at line 3”.

Use .with_context(|| ...) when the message involves formatting (the closure is only evaluated on error). Use .context("...") for static strings.

bail! and ensure!

For errors that don’t originate from another error type:

use anyhow::{bail, ensure, Result};

fn validate_port(port: u16) -> Result<()> {
    ensure!(port >= 1024, "port {port} is in the privileged range");
    if port == 0 {
        bail!("port must not be zero");
    }
    Ok(())
}

bail! returns early with an error. ensure! is a conditional bail!.

When to use anyhow

Use anyhow in application-level code where you don’t need callers to match on specific error variants: startup logic, configuration loading, background tasks, and any place where the only reasonable response to an error is to log it and return a 500. Anyhow’s .context() method produces better diagnostic messages than a bare ?, because each layer of the call stack can explain what it was trying to do.

The AppError type

Web handlers need to convert errors into HTTP responses. Axum requires that both the success and error types in a handler’s Result implement IntoResponse. The standard pattern is a single AppError enum that maps each variant to an HTTP status code.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found: {0}")]
    NotFound(String),

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("bad request: {0}")]
    BadRequest(String),

    #[error("unauthorized")]
    Unauthorized,

    #[error("database error")]
    Database(#[from] sqlx::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match &self {
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
            AppError::Conflict(_) => StatusCode::CONFLICT,
            AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
            AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
        };

        (status, self.to_string()).into_response()
    }
}

Handlers return Result<impl IntoResponse, AppError>:

async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<i64>,
) -> Result<Html<String>, AppError> {
    let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_optional(&pool)
        .await?  // sqlx::Error -> AppError::Database
        .ok_or_else(|| AppError::NotFound(format!("user {id}")))?;

    Ok(Html(render_user(&user)))
}

The ? operator on the SQLx call converts sqlx::Error into AppError::Database via the #[from] attribute. The .ok_or_else() on the Option produces AppError::NotFound when the query returns no rows.

Converting from domain errors

Domain modules often define their own error types. Add From implementations to convert them into AppError:

impl From<UserError> for AppError {
    fn from(err: UserError) -> Self {
        match err {
            UserError::NotFound(id) => AppError::NotFound(format!("user {id}")),
            UserError::DuplicateEmail(email) => {
                AppError::Conflict(format!("email {email} already registered"))
            }
            UserError::InvalidEmail(msg) => AppError::BadRequest(msg),
        }
    }
}

This mapping is where domain semantics meet HTTP semantics. A DuplicateEmail is a domain concept; a 409 Conflict is an HTTP concept. The From implementation is the bridge.

Mapping database errors

Most SQLx errors should map to a 500. The two cases worth handling explicitly are missing rows and unique constraint violations:

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match &err {
            sqlx::Error::RowNotFound => {
                AppError::NotFound("record not found".to_string())
            }
            sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
                AppError::Conflict("duplicate record".to_string())
            }
            _ => AppError::Database(err),
        }
    }
}

Prefer fetch_optional() over fetch_one() when a missing row is a normal case rather than an error. fetch_optional() returns Ok(None) instead of Err(RowNotFound), which keeps the “not found” logic in the handler where you have more context about what was being looked up:

let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
    .fetch_optional(&pool)
    .await?
    .ok_or_else(|| AppError::NotFound(format!("user {id}")))?;

This produces a better error message (“user 42 not found”) than the generic “record not found” from the From<sqlx::Error> conversion.

Never expose raw database error messages to users. They can leak table names, column names, and constraint details. The From impl above replaces the database message with a generic string. Log the original error for debugging (covered below).

User-facing error pages

The plain text responses above work for development. For a production HDA application, render HTML error pages with Maud.

Define an error page component:

use maud::{html, Markup};
use axum::http::StatusCode;

fn error_page(status: StatusCode, message: &str) -> Markup {
    html! {
        h1 { (status.as_u16()) " " (status.canonical_reason().unwrap_or("Error")) }
        p { (message) }
    }
}

Wrap it in your site layout the same way you wrap any other page. The error page should look like part of the application, not a raw browser error.

Update IntoResponse to render HTML:

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Unauthorized => {
                (StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
            }
            AppError::Database(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "An internal error occurred".to_string(),
            ),
        };

        (status, error_page(status, &message)).into_response()
    }
}

For 500 errors, show a generic message. The user does not need to know that the database connection timed out.

Error fragments for htmx requests

When an htmx request fails, you often want to return an error fragment that slots into the existing page rather than a full error page. Check the HX-Request header to branch:

use axum_htmx::HxRequest;

async fn delete_user(
    HxRequest(is_htmx): HxRequest,
    State(pool): State<PgPool>,
    Path(id): Path<i64>,
) -> Result<impl IntoResponse, AppError> {
    delete_user_by_id(&pool, id).await?;

    if is_htmx {
        Ok(Html("".to_string()).into_response())
    } else {
        Ok(Redirect::to("/users").into_response())
    }
}

For a more systematic approach, move the htmx check into the IntoResponse implementation. This requires access to the request headers, which IntoResponse does not have. One option is to store the HxRequest value in the AppError type or use a middleware that sets a response header. In practice, handling htmx errors at the handler level (as above) is simpler and more explicit.

Logging errors with tracing

Log errors in the IntoResponse implementation so every error is captured in one place. This avoids scattering tracing::error! calls across every handler.

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => {
                tracing::warn!(error = %self, "not found");
                (StatusCode::NOT_FOUND, msg.clone())
            }
            AppError::Conflict(msg) => {
                tracing::warn!(error = %self, "conflict");
                (StatusCode::CONFLICT, msg.clone())
            }
            AppError::BadRequest(msg) => {
                tracing::warn!(error = %self, "bad request");
                (StatusCode::BAD_REQUEST, msg.clone())
            }
            AppError::Unauthorized => {
                tracing::warn!("unauthorized request");
                (StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
            }
            AppError::Database(err) => {
                tracing::error!(error = ?err, "database error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "An internal error occurred".to_string(),
                )
            }
        };

        (status, error_page(status, &message)).into_response()
    }
}

Expected errors (not found, bad request) log at warn level with Display formatting (%). Unexpected errors (database failures) log at error level with Debug formatting (?) to capture the full error chain. This distinction keeps log noise manageable while ensuring genuine problems are visible.

Supplementary logging with #[instrument]

For handlers where you need more context about what failed, add #[instrument(err)] to log the error along with the function’s arguments:

use tracing::instrument;

#[instrument(skip(pool), err)]
async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<i64>,
) -> Result<Html<String>, AppError> {
    // If this returns Err, tracing logs the error with id as context
    let user = find_user(&pool, id).await?;
    Ok(Html(render_user(&user)))
}

skip(pool) prevents the database pool from being included in the log output (it produces enormous Debug output). err tells #[instrument] to log the error value when the function returns Err.

This is supplementary to the centralized logging in IntoResponse. Use it when you need to know which specific handler call failed and with what arguments. For most handlers, the centralized approach is sufficient.

The anyhow catch-all alternative

The AppError enum above requires a variant (or a From implementation) for every error source. As applications grow, this can mean a lot of conversion code. An alternative is to add an anyhow-based catch-all variant:

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found: {0}")]
    NotFound(String),

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("bad request: {0}")]
    BadRequest(String),

    #[error("unauthorized")]
    Unauthorized,

    #[error(transparent)]
    Unexpected(#[from] anyhow::Error),
}

The Unexpected variant accepts any error that implements std::error::Error via anyhow’s blanket From implementation. Any error you haven’t explicitly handled falls through to this variant and becomes a 500.

This pattern is documented in the thiserror README and taught in Zero to Production in Rust. It trades explicit control for ergonomics: you no longer need to declare a variant or write a From impl for every error source, but unexpected errors all become opaque 500s with no chance for finer-grained status codes.

The trade-off is worth considering as your application grows. For a small application with a handful of error sources, explicit variants are manageable and give you full control over HTTP status codes. For a larger application with many fallible operations where most errors are genuinely unexpected, the catch-all reduces boilerplate.

Placement in the workspace

In a Cargo workspace, place the AppError type and its IntoResponse implementation in a shared crate (e.g., web or common). Domain error types (like UserError, OrderError) live in their respective domain crates with From implementations in the shared crate that bridge domain errors to AppError.

workspace/
├── crates/
│   ├── common/         # AppError, IntoResponse, shared types
│   ├── users/          # UserError, user domain logic
│   ├── orders/         # OrderError, order domain logic
│   └── web/            # Axum handlers, routes

Domain crates depend on thiserror. The shared crate depends on thiserror and axum. Domain crates do not depend on axum, keeping web concerns out of business logic.

Gotchas

Don’t expose internal details to users. Database errors, file paths, and stack traces belong in logs, not in HTTP responses. The IntoResponse implementation should return generic messages for 500 errors and log the real error separately.

sqlx::Error is non-exhaustive. Always include a catch-all arm when matching on it. New variants can be added in minor releases.

fetch_optional vs fetch_one. Use fetch_optional when a missing row is expected (looking up a user by ID). Use fetch_one when a missing row is a bug (fetching a record you just inserted). fetch_one returns Err(RowNotFound) on miss; fetch_optional returns Ok(None).

Order of From implementations matters. If AppError has both From<sqlx::Error> and From<RepoError>, and RepoError also wraps sqlx::Error, a bare ? on a SQLx call in a handler will use From<sqlx::Error> directly, bypassing the domain error’s semantics. Be explicit about which conversion you want.

thiserror 2.0 requires a direct dependency. Code using derive(Error) must declare thiserror as a direct dependency in its Cargo.toml. Relying on a transitive dependency no longer works as of thiserror 2.0.