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
IntoResponseto control HTTP status and body thiserrorgeneratesDisplayandErrorboilerplate from derive macros#[from] SomeErrorlets?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.