Updated Aug 27, 2026

04. Error Handling

📋 Jump to Takeaways

🎁 Every handler returns Result. The question is what happens to the Err — does it become a 500 with a stack trace leaking to the client, or a structured JSON response with a meaningful status code?

The Problem

Axum handlers can return Result<T, E>, but E must implement IntoResponse. The standard ? operator works, but by default errors become opaque 500s with no useful body.

You need one thing: a custom error type that knows how to turn itself into a proper HTTP response.

thiserror

[dependencies]
thiserror = "2"

thiserror generates Display and Error implementations from your enum variants — less boilerplate than writing them by hand.

Define the Error Type

Create src/error.rs:

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

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

    #[error("invalid input: {0}")]
    InvalidInput(String),

    #[error("database error: {0}")]
    Database(String),

    #[error("internal error")]
    Internal,
}

#[derive(Serialize)]
struct ErrorBody {
    error: String,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound         => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::InvalidInput(m)  => (StatusCode::BAD_REQUEST, m.clone()),
            AppError::Database(_)      => (StatusCode::INTERNAL_SERVER_ERROR, "database error".to_string()),
            AppError::Internal         => (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string()),
        };

        (status, Json(ErrorBody { error: message })).into_response()
    }
}

The Database variant wraps an error message string. Once you add the sqlx dependency in the SQLx lesson, you'll upgrade this to Database(#[from] sqlx::Error) — that's the thiserror attribute that lets ? automatically convert any sqlx::Error into AppError::Database.

Using AppError in Handlers

use crate::error::AppError;
use axum::{extract::Path, Json};

pub async fn get_link(Path(code): Path<String>) -> Result<Json<Link>, AppError> {
    if code.is_empty() {
        return Err(AppError::InvalidInput("code cannot be empty".to_string()));
    }

    // database lookup (covered in the SQLx lesson)
    let link = find_by_code(&code).await
        .ok_or(AppError::NotFound)?;

    Ok(Json(link))
}

Now every error path returns a consistent JSON body:

{"error": "not found"}
{"error": "code cannot be empty"}

Handling Extractor Failures

When Json<T> fails to deserialize the request body, Axum returns a 422 by default. To return your AppError instead, write a thin wrapper extractor that delegates to axum::Json internally:

use axum::{extract::{rejection::JsonRejection, FromRequest, Request}};
use serde::de::DeserializeOwned;

pub struct ValidJson<T>(pub T);

impl<T, S> FromRequest<S> for ValidJson<T>
where
    T: DeserializeOwned,
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        axum::Json::<T>::from_request(req, state)
            .await
            .map(|axum::Json(v)| ValidJson(v))
            .map_err(|e: JsonRejection| AppError::InvalidInput(e.body_text()))
    }
}

Use ValidJson<T> in place of axum::Json<T> in handler signatures and deserialization failures automatically map to AppError::InvalidInput. The Extractors lesson covers this pattern in full — including validation on top of deserialization.

Logging Errors

The Database and Internal variants hide the real error from the client — intentionally. Log it server-side instead. Replace the Database(_) arm in the IntoResponse match above with:

AppError::Database(e) => {
    tracing::error!("database error: {}", e);
    (StatusCode::INTERNAL_SERVER_ERROR, "database error".to_string())
}

Never leak internal error details to clients. Log them, return a generic message.

Key Takeaways

  • Custom error types implement IntoResponse to control HTTP status and body
  • thiserror generates Display and Error boilerplate from derive macros
  • #[from] SomeError lets ? automatically convert from that type
  • Map errors to status codes in into_response — never leak internal details to clients
  • Log internal errors server-side; return generic messages to the client
  • Consistent JSON error bodies ({"error": "..."}) make client error handling predictable

🎁 Your handlers work, but they're reading the database URL and port from hardcoded strings. What happens when those change between dev and prod? Next up: loading configuration from environment variables safely.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy