← Week 3: Axum Web Framework

Day 18: Error Handling in Axum

Phase 2 · Jun 27, 2026

← Week 3: Axum Web Framework

Agenda (2–3 hours)

  • Read (45 min): Axum error handling documentation; the thiserror and anyhow crate READMEs
  • Study (45 min): Why does anyhow::Error NOT implement IntoResponse? What's the idiomatic bridge?
  • Practice (45 min): Define an AppError type with thiserror; implement IntoResponse with structured JSON error responses; use ? throughout handlers
  • Challenge (30 min): Add a request ID to every error response so callers can correlate errors with server logs
← Week 3: Axum Web Framework

The Problem

Axum handlers can return Result<T, E> only if E: IntoResponse.

anyhow::Error and most library errors don't implement IntoResponse. You need a bridge:

// Wrong: anyhow::Error doesn't implement IntoResponse
async fn handler() -> Result<Json<User>, anyhow::Error> { ... }

// Right: wrap in your AppError
async fn handler() -> Result<Json<User>, AppError> { ... }
← Week 3: Axum Web Framework

Defining AppError

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("unauthorized")]
    Unauthorized,
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("internal error")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
            AppError::Database(_) | AppError::Internal(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}
← Week 3: Axum Web Framework

Using ? in Handlers

async fn get_user(
    Path(id): Path<u64>,
    State(state): State<AppState>,
) -> Result<Json<User>, AppError> {
    let user = state.db
        .find_user(id)
        .await?                          // sqlx::Error → AppError::Database (via From)
        .ok_or(AppError::NotFound)?;     // Option → AppError::NotFound
    Ok(Json(user))
}

Explicit .ok_or() vs ? for Option vs Result is the main ergonomic pattern.

← Week 3: Axum Web Framework

Structured Error Responses

{
  "error": "not found",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2026-06-27T14:23:11Z"
}

Inject request ID via middleware extensions; read it in IntoResponse:

// In AppError::into_response:
let request_id = /* read from thread-local or extension */;
(status, Json(json!({
    "error": message,
    "request_id": request_id,
}))).into_response()
← Week 3: Axum Web Framework

Key Takeaways

  • thiserror for library/service error enums; anyhow for application code and quick prototyping
  • Bridge to Axum by implementing IntoResponse on your error type
  • Use #[from] in thiserror to get automatic From impls for ? conversion
  • Never expose internal error details in HTTP responses — log them, return generic messages

Tomorrow: WebSockets with Axum — full-duplex messaging.