Error Handling at Scale
Rust does not have exceptions. Every function that can fail returns a Result<T, E>. At first this seems verbose. After working with it on a real service, most engineers find it is the first error system that actually scales — because every failure path is visible in the type signature.
The challenge is not getting Result to work. It is building an error hierarchy that is coherent across a 50-module service without becoming a maintenance burden.
The Baseline: Result and the ? Operator
Result<T, E> is an enum with two variants: Ok(T) for success and Err(E) for failure. The ? operator propagates errors automatically.
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // propagates io::Error if it fails
Ok(contents)
}
fn main() {
match read_config("/etc/myapp/config.toml") {
Ok(config) => println!("loaded: {} bytes", config.len()),
Err(e) => eprintln!("failed to load config: {}", e),
}
}The ? operator desugars to: if this is Err(e), return Err(e.into()) from the current function. The .into() is the key — it calls From::from to convert the error type. This is the glue that connects the error ecosystem.
thiserror: Domain Error Types
For library code and service layers with meaningful error variants, thiserror generates the boilerplate you would otherwise write by hand.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UserError {
#[error("user {0} not found")]
NotFound(u64),
#[error("email address is invalid: {email}")]
InvalidEmail { email: String },
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("permission denied for user {user_id} on resource {resource}")]
Forbidden { user_id: u64, resource: String },
}#[from] implements From<sqlx::Error> for UserError automatically. Now ? converts database errors into UserError::Database without any manual glue.
async fn get_user(id: u64, pool: &sqlx::PgPool) -> Result<User, UserError> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id as i64)
.fetch_optional(pool)
.await? // sqlx::Error → UserError::Database via #[from]
.ok_or(UserError::NotFound(id))?;
if !is_valid_email(&user.email) {
return Err(UserError::InvalidEmail { email: user.email });
}
Ok(user)
}anyhow: Application Error Handling
thiserror is for libraries and service layers that need callers to distinguish error variants. anyhow is for application code (binary crates, top-level handlers) where you mostly want to propagate and log errors with context.
use anyhow::{Context, Result};
async fn startup() -> Result<()> {
let config = load_config()
.context("failed to load configuration")?;
let pool = connect_database(&config.database_url)
.await
.context("failed to connect to database")?;
run_migrations(&pool)
.await
.context("database migrations failed")?;
Ok(())
}
fn load_config() -> Result<AppConfig> {
let raw = std::fs::read_to_string("/etc/myapp/config.toml")
.context("reading config file")?;
toml::from_str(&raw).context("parsing config toml")
}anyhow::Result<T> is an alias for Result<T, anyhow::Error>. anyhow::Error holds any error type plus a backtrace and optional context chain. You get human-readable error traces like:
Error: failed to load configuration
Caused by:
0: reading config file
1: No such file or directory (os error 2)Choosing Between thiserror and anyhow
The practical rule: use thiserror in src/domain/, src/repository/, and src/service/; use anyhow in src/main.rs, src/startup.rs, and HTTP handlers where you convert errors to responses anyway.
HTTP Error Responses
In a web service, domain errors need to map to HTTP responses. The cleanest pattern is implementing IntoResponse from Axum (or equivalent in other frameworks) on your error type.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
impl IntoResponse for UserError {
fn into_response(self) -> Response {
let (status, message) = match &self {
UserError::NotFound(id) => (
StatusCode::NOT_FOUND,
format!("user {} not found", id),
),
UserError::InvalidEmail { email } => (
StatusCode::UNPROCESSABLE_ENTITY,
format!("invalid email: {}", email),
),
UserError::Forbidden { .. } => (
StatusCode::FORBIDDEN,
"permission denied".to_string(),
),
UserError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
),
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}Now your handler return type carries the full error semantics and the HTTP layer converts automatically:
async fn get_user_handler(
Path(id): Path<u64>,
State(pool): State<PgPool>,
) -> Result<Json<User>, UserError> {
let user = get_user(id, &pool).await?;
Ok(Json(user))
}Structured Error Logging
Domain errors know their type. Use that to emit structured logs before returning the HTTP response.
use tracing::{error, warn};
impl IntoResponse for UserError {
fn into_response(self) -> Response {
match &self {
UserError::Database(e) => {
error!(error = %e, "database error in request handler");
}
UserError::NotFound(id) => {
warn!(user_id = id, "user not found");
}
_ => {}
}
// ... status and body as before
todo!()
}
}This gives you error counts broken down by variant in your observability stack without any additional instrumentation.
Error Propagation Across the Stack
Each layer uses ? to propagate. The #[from] attribute on UserError::Database means the sqlx::Error is automatically wrapped when it crosses the repository boundary. No manual conversion code at each layer.
Key Takeaways
Result<T, E>and?are Rust's complete error model — no exceptions, no surprises in call sites.- Use
thiserrorin domain and service layers where callers need to match on specific error variants; the#[from]attribute eliminates conversion boilerplate. - Use
anyhowin application-level code and startup paths where you care about context and messages, not variant matching. - Implement
IntoResponseon your domain error type to get clean, consistent HTTP error responses without repetition in every handler. - Emit structured logs from the error's
IntoResponseimplementation to get automatic error metrics broken down by type. - A three-layer error model (domain errors with thiserror, application errors with anyhow, HTTP mapping with IntoResponse) covers most backend service architectures cleanly.