Skip to main content
Rust for Backend Engineers

Error Handling at Scale

Ravinder··5 min read
RustBackendError Handlingthiserroranyhow
Share:
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

flowchart TD A[New error type needed] --> B{Is this a library or service layer?} B -- Library/service layer --> C{Do callers need to match on variants?} C -- Yes --> D[Use thiserror with enum variants] C -- No, just propagate --> E[Consider anyhow in this layer too] B -- Application binary / handler --> F[Use anyhow for context + propagation] D --> G[thiserror] E --> H[anyhow or Box] F --> I[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

sequenceDiagram participant Handler participant Service participant Repository participant DB Handler->>Service: get_user(id) Service->>Repository: find_user(id) Repository->>DB: SQL query DB-->>Repository: sqlx::Error Repository-->>Service: Err(UserError::Database) Service-->>Handler: Err(UserError::Database) Handler->>Handler: log error Handler-->>Client: 500 JSON response

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 thiserror in domain and service layers where callers need to match on specific error variants; the #[from] attribute eliminates conversion boilerplate.
  • Use anyhow in application-level code and startup paths where you care about context and messages, not variant matching.
  • Implement IntoResponse on your domain error type to get clean, consistent HTTP error responses without repetition in every handler.
  • Emit structured logs from the error's IntoResponse implementation 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.
Share: