Skip to main content
โšก Calmops

Error Handling Patterns in Rust Web Services

Building Resilient APIs with Type-Safe Error Management

Introduction

Error handling is often an afterthought in web service development. But in production systems, how you handle errors is just as important as the happy path. A well-designed error handling strategy:

  • Prevents cascading failures - Isolates issues to specific services
  • Enables debugging - Provides actionable information about what went wrong
  • Improves UX - Returns meaningful error responses to clients
  • Maintains security - Avoids leaking sensitive information
  • Scales reliably - Handles edge cases gracefully

Rust’s type system and error handling philosophy makes building robust error strategies straightforward. Unlike languages that use exceptions (which hide control flow), Rust forces you to handle errors explicitly through Result<T, E> types.

This article explores battle-tested error handling patterns used in production Rust web services.


Part 1: Core Concepts

Result<T, E>: The Foundation

In Rust, functions that can fail return a Result<T, E>:

  • Ok(T) - Success with value of type T
  • Err(E) - Failure with error of type E
// A function that might fail
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// Usage: You MUST handle both cases
match divide(10.0, 2.0) {
    Ok(result) => println!("Result: {}", result),
    Err(error) => println!("Error: {}", error),
}

This is fundamentally different from exceptions. There’s no hidden control flowโ€”you explicitly handle errors where they occur.

The ? Operator: Propagating Errors

Instead of writing verbose match expressions, Rust provides the ? operator to propagate errors:

// BAD: Verbose error handling
fn fetch_and_parse() -> Result<Data, Error> {
    match fetch_data() {
        Ok(response) => {
            match parse_response(response) {
                Ok(data) => Ok(data),
                Err(e) => Err(e),
            }
        }
        Err(e) => Err(e),
    }
}

// GOOD: Using ? operator
fn fetch_and_parse() -> Result<Data, Error> {
    let response = fetch_data()?;  // If error, return immediately
    let data = parse_response(response)?;
    Ok(data)
}

The ? operator extracts the value or returns the error. This enables clean, readable code while maintaining explicit error propagation.

Error Types: thiserror vs anyhow

Rust offers two main approaches:

thiserror - For libraries and well-defined errors:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum UserServiceError {
    #[error("User not found")]
    UserNotFound,
    
    #[error("Invalid email format: {0}")]
    InvalidEmail(String),
    
    #[error("Database error: {0}")]
    DatabaseError(#[from] sqlx::Error),
    
    #[error("Internal server error")]
    InternalError,
}

anyhow - For applications with flexible error handling:

use anyhow::{Result, Context};

fn fetch_user(id: u64) -> Result<User> {
    let user = db.query(id)
        .context("Failed to query database")?;
    
    Ok(user)
}

// Usage
match fetch_user(123) {
    Ok(user) => println!("User: {}", user.name),
    Err(e) => eprintln!("Error chain:\n{:?}", e),  // Shows full context
}

When to use:

  • thiserror - Public APIs, libraries where callers need to pattern match specific errors
  • anyhow - Applications where you primarily care about the error message chain

Part 2: HTTP Error Responses

Web services must convert internal errors into HTTP responses that clients understand.

Custom Error Type for Web Services

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("User not found")]
    UserNotFound,
    
    #[error("Invalid input: {0}")]
    ValidationError(String),
    
    #[error("Unauthorized")]
    Unauthorized,
    
    #[error("Conflict: {0}")]
    Conflict(String),
    
    #[error("Internal server error")]
    InternalError,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ApiError::UserNotFound => (
                StatusCode::NOT_FOUND,
                "User not found",
            ),
            ApiError::ValidationError(msg) => (
                StatusCode::BAD_REQUEST,
                &msg,
            ),
            ApiError::Unauthorized => (
                StatusCode::UNAUTHORIZED,
                "Unauthorized",
            ),
            ApiError::Conflict(msg) => (
                StatusCode::CONFLICT,
                &msg,
            ),
            ApiError::InternalError => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Internal server error",
            ),
        };

        let body = Json(json!({
            "error": error_message,
            "status": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

// Now your handler can return ApiError directly
async fn get_user(
    Path(id): Path<u64>,
) -> Result<Json<User>, ApiError> {
    let user = fetch_user(id)
        .await
        .map_err(|_| ApiError::UserNotFound)?;
    
    Ok(Json(user))
}

This design automatically converts errors to appropriate HTTP responses.

Contextual Error Responses

Always include context in error messages, but never leak sensitive information:

use tracing::{error, warn};

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Database error")]
    DatabaseError,
    
    #[error("Invalid user email")]
    InvalidEmail,
}

async fn create_user(email: String) -> Result<User, ApiError> {
    // Validate email format
    if !email.contains('@') {
        warn!("Invalid email attempt: {}", email);  // Log it
        return Err(ApiError::InvalidEmail);         // Don't leak to client
    }

    // Query database
    db.insert_user(&email)
        .await
        .map_err(|db_err| {
            error!("Database error: {}", db_err);   // Log full error
            ApiError::DatabaseError                 // Hide from client
        })?;

    Ok(user)
}

Key principle: Log detailed errors server-side, return generic messages to clients.


Part 3: Layered Error Handling Architecture

Production services often have multiple layers. Each layer should handle errors appropriately:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  HTTP Handler Layer (Axum)                          โ”‚
โ”‚  - Converts ApiError to HTTP responses              โ”‚
โ”‚  - Returns 400/401/500 status codes                 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                      โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Business Logic Layer (Service)                     โ”‚
โ”‚  - Domain-specific errors                           โ”‚
โ”‚  - Validates invariants                             โ”‚
โ”‚  - Logs detailed errors                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                      โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Data Access Layer (Repository)                     โ”‚
โ”‚  - Database errors                                  โ”‚
โ”‚  - Converts to application errors                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                      โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  External Dependencies (Libraries)                  โ”‚
โ”‚  - sqlx, reqwest, serde errors                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Implementation Example

// Layer 1: Domain errors
#[derive(Error, Debug)]
pub enum UserServiceError {
    #[error("User not found")]
    NotFound,
    
    #[error("User already exists")]
    AlreadyExists,
    
    #[error("Invalid email")]
    InvalidEmail,
    
    #[error("Database error")]
    DatabaseError,
}

// Layer 2: Repository (data access)
pub struct UserRepository {
    pool: SqlitePool,
}

impl UserRepository {
    pub async fn find_by_id(&self, id: u64) -> Result<User, UserServiceError> {
        sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
            .bind(id)
            .fetch_optional(&self.pool)
            .await
            .map_err(|_| UserServiceError::DatabaseError)?
            .ok_or(UserServiceError::NotFound)
    }

    pub async fn create(&self, email: &str) -> Result<User, UserServiceError> {
        // Check if exists
        let existing = sqlx::query("SELECT id FROM users WHERE email = ?")
            .bind(email)
            .fetch_optional(&self.pool)
            .await
            .map_err(|_| UserServiceError::DatabaseError)?;

        if existing.is_some() {
            return Err(UserServiceError::AlreadyExists);
        }

        // Insert new user
        let user_id = sqlx::query("INSERT INTO users (email) VALUES (?)")
            .bind(email)
            .execute(&self.pool)
            .await
            .map_err(|_| UserServiceError::DatabaseError)?
            .last_insert_rowid();

        Ok(User {
            id: user_id,
            email: email.to_string(),
        })
    }
}

// Layer 3: Service (business logic)
pub struct UserService {
    repo: UserRepository,
}

impl UserService {
    pub async fn get_user(&self, id: u64) -> Result<User, UserServiceError> {
        self.repo.find_by_id(id).await
    }

    pub async fn register_user(&self, email: &str) -> Result<User, UserServiceError> {
        // Validate email format
        if !email.contains('@') {
            return Err(UserServiceError::InvalidEmail);
        }

        self.repo.create(email).await
    }
}

// Layer 4: API endpoint (HTTP handler)
#[derive(Error, Debug)]
pub enum ApiError {
    #[error("User not found")]
    UserNotFound,
    
    #[error("Invalid email format")]
    InvalidEmail,
    
    #[error("User already exists")]
    UserExists,
    
    #[error("Internal server error")]
    InternalError,
}

impl From<UserServiceError> for ApiError {
    fn from(err: UserServiceError) -> Self {
        match err {
            UserServiceError::NotFound => ApiError::UserNotFound,
            UserServiceError::InvalidEmail => ApiError::InvalidEmail,
            UserServiceError::AlreadyExists => ApiError::UserExists,
            UserServiceError::DatabaseError => ApiError::InternalError,
        }
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::UserNotFound => (StatusCode::NOT_FOUND, "User not found"),
            ApiError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid email"),
            ApiError::UserExists => (StatusCode::CONFLICT, "User already exists"),
            ApiError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
        };

        let body = Json(json!({
            "error": message,
            "status": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

async fn register_user_handler(
    Json(payload): Json<serde_json::Value>,
) -> Result<Json<User>, ApiError> {
    let email = payload["email"]
        .as_str()
        .ok_or(ApiError::InvalidEmail)?;

    let service = UserService { repo: /* ... */ };
    let user = service.register_user(email).await?;  // Converts UserServiceError to ApiError

    Ok(Json(user))
}

Part 4: Structured Error Logging

Production services must log errors for debugging and monitoring.

use tracing::{debug, warn, error, span, Level};
use std::backtrace::Backtrace;

#[derive(Error, Debug)]
#[error("Failed to fetch user: {reason}")]
pub struct FetchUserError {
    pub reason: String,
    #[source]
    pub source: Option<Box<dyn std::error::Error>>,
    backtrace: Backtrace,
}

async fn fetch_user_with_logging(id: u64) -> Result<User, FetchUserError> {
    let span = span!(Level::DEBUG, "fetch_user", user_id = id);
    let _enter = span.enter();

    debug!("Starting user fetch");

    match db.query(id).await {
        Ok(user) => {
            debug!("User fetch successful");
            Ok(user)
        }
        Err(db_err) => {
            error!(
                error = %db_err,
                backtrace = %Backtrace::capture(),
                "Database query failed"
            );
            Err(FetchUserError {
                reason: format!("Database error: {}", db_err),
                source: Some(Box::new(db_err)),
                backtrace: Backtrace::capture(),
            })
        }
    }
}

// Initialize tracing (typically in main)
fn init_tracing() {
    tracing_subscriber::fmt()
        .with_max_level(Level::INFO)
        .with_target(true)
        .with_line_number(true)
        .json()  // JSON output for log aggregation
        .init();
}

Best practices:

  • Use structured logging (JSON) for machine parsing
  • Include request IDs for tracing across services
  • Log error context but not sensitive data
  • Use spans to correlate related operations

Part 5: Common Pitfalls & Best Practices

โŒ Pitfall: Generic Error Types

// BAD: Too generic
fn process_data(input: &str) -> Result<Data, String> {
    // Caller can't distinguish between different error types
    // Hard to handle specific errors differently
}

// GOOD: Specific error type
#[derive(Error, Debug)]
pub enum ProcessError {
    #[error("Invalid format")]
    InvalidFormat,
    
    #[error("I/O error: {0}")]
    IoError(#[from] std::io::Error),
}

fn process_data(input: &str) -> Result<Data, ProcessError> {
    // Caller can pattern match on specific errors
}

Why it matters: Generic strings make it impossible to handle different errors differently at the call site.

โŒ Pitfall: Losing Error Context

// BAD: Error context lost
async fn create_user(email: &str) -> Result<User> {
    db.insert(email)
        .await
        .map_err(|_| anyhow::anyhow!("Database error"))  // Lost original error!
}

// GOOD: Preserve error chain
async fn create_user(email: &str) -> Result<User> {
    db.insert(email)
        .await
        .context("Failed to insert user into database")?;
    
    Ok(user)
}

Why it matters: When debugging, the error chain tells the story. anyhow::context() preserves the full chain.

โŒ Pitfall: Panicking on Expected Errors

// BAD: Panics on recoverable errors
async fn get_user(id: u64) -> User {
    db.find(id)
        .await
        .expect("User must exist")  // Crashes entire service!
}

// GOOD: Handle gracefully
async fn get_user(id: u64) -> Result<User, ApiError> {
    db.find(id)
        .await
        .map_err(|_| ApiError::UserNotFound)
}

Why it matters: expect() panics crash the entire service. In web services, this is catastrophic.

โœ… Best Practice: Custom Result Type

// Define once, use everywhere
pub type Result<T> = std::result::Result<T, ApiError>;

// Much cleaner signatures
async fn get_user(id: u64) -> Result<User> {
    // Implementation
}

async fn create_user(email: &str) -> Result<User> {
    // Implementation
}

โœ… Best Practice: Error Mapping at Boundaries

// Map external errors at system boundaries
async fn fetch_from_api(url: &str) -> Result<Data, ApiError> {
    reqwest::get(url)
        .await
        .map_err(|e| {
            error!("HTTP request failed: {}", e);
            ApiError::ExternalServiceError
        })?
        .json()
        .await
        .map_err(|e| {
            error!("JSON deserialization failed: {}", e);
            ApiError::InternalError
        })?
}

Why it matters: Map specific library errors to your domain errors only at boundaries, not throughout code.

โœ… Best Practice: Avoid Propagating Too Much

// BAD: Bubbles all errors up
async fn process_items(items: Vec<Item>) -> Result<Vec<Data>, Error> {
    items.iter()
        .map(|item| process_single(item))  // If any fails, entire operation fails
        .collect()
}

// GOOD: Handle partial failures gracefully
async fn process_items(items: Vec<Item>) -> Result<Vec<Data>> {
    let mut results = Vec::new();
    
    for item in items {
        match process_single(&item).await {
            Ok(data) => results.push(data),
            Err(e) => {
                warn!("Failed to process item {}: {}", item.id, e);
                // Continue processing remaining items
            }
        }
    }
    
    if results.is_empty() {
        Err(anyhow::anyhow!("All items failed to process"))
    } else {
        Ok(results)
    }
}

Part 6: Real-World Example: Complete API with Error Handling

use axum::{
    extract::{Path, Json},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use thiserror::Error;
use tracing::{info, error};

// Domain models
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
    pub id: u64,
    pub email: String,
    pub created_at: String,
}

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
}

// Error types
#[derive(Error, Debug)]
pub enum UserError {
    #[error("User not found")]
    NotFound,
    
    #[error("Email already exists")]
    EmailExists,
    
    #[error("Invalid email")]
    InvalidEmail,
    
    #[error("Database error")]
    DatabaseError,
}

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("User not found")]
    NotFound,
    
    #[error("Email already in use")]
    EmailExists,
    
    #[error("Invalid input")]
    InvalidInput,
    
    #[error("Internal server error")]
    InternalError,
}

impl From<UserError> for ApiError {
    fn from(err: UserError) -> Self {
        match err {
            UserError::NotFound => ApiError::NotFound,
            UserError::EmailExists => ApiError::EmailExists,
            UserError::InvalidEmail => ApiError::InvalidInput,
            UserError::DatabaseError => ApiError::InternalError,
        }
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "User not found"),
            ApiError::EmailExists => (StatusCode::CONFLICT, "Email already in use"),
            ApiError::InvalidInput => (StatusCode::BAD_REQUEST, "Invalid input"),
            ApiError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
        };

        let body = Json(serde_json::json!({
            "error": error_message,
            "status": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

// Repository layer
pub struct UserRepository {
    pool: SqlitePool,
}

impl UserRepository {
    pub async fn find_by_id(&self, id: u64) -> Result<User, UserError> {
        sqlx::query_as::<_, User>(
            "SELECT id, email, created_at FROM users WHERE id = ?"
        )
            .bind(id)
            .fetch_optional(&self.pool)
            .await
            .map_err(|_| UserError::DatabaseError)?
            .ok_or(UserError::NotFound)
    }

    pub async fn create(&self, email: &str) -> Result<User, UserError> {
        // Validate email
        if !email.contains('@') {
            return Err(UserError::InvalidEmail);
        }

        // Check for duplicates
        let existing = sqlx::query("SELECT id FROM users WHERE email = ?")
            .bind(email)
            .fetch_optional(&self.pool)
            .await
            .map_err(|_| UserError::DatabaseError)?;

        if existing.is_some() {
            return Err(UserError::EmailExists);
        }

        // Insert
        let now = chrono::Utc::now().to_rfc3339();
        let user_id = sqlx::query(
            "INSERT INTO users (email, created_at) VALUES (?, ?)"
        )
            .bind(email)
            .bind(&now)
            .execute(&self.pool)
            .await
            .map_err(|_| UserError::DatabaseError)?
            .last_insert_rowid();

        Ok(User {
            id: user_id,
            email: email.to_string(),
            created_at: now,
        })
    }
}

// Service layer
pub struct UserService {
    repo: UserRepository,
}

impl UserService {
    pub async fn get_user(&self, id: u64) -> Result<User, UserError> {
        self.repo.find_by_id(id).await
    }

    pub async fn register_user(&self, email: &str) -> Result<User, UserError> {
        self.repo.create(email).await
    }
}

// Handler layer
pub async fn get_user_handler(
    Path(id): Path<u64>,
) -> Result<Json<User>, ApiError> {
    info!("Fetching user {}", id);
    
    let service = UserService { repo: /* ... */ };
    let user = service.get_user(id).await?;
    
    Ok(Json(user))
}

pub async fn create_user_handler(
    Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    info!("Creating user with email: {}", payload.email);
    
    let service = UserService { repo: /* ... */ };
    let user = service.register_user(&payload.email).await?;
    
    info!("User created: {}", user.id);
    Ok((StatusCode::CREATED, Json(user)))
}

// Router setup
pub fn app(repo: UserRepository) -> Router {
    Router::new()
        .route("/users/:id", get(get_user_handler))
        .route("/users", post(create_user_handler))
}

Part 7: Comparisons with Other Languages

Aspect Rust Python Go Java
Error Handling Explicit Result type Exceptions (implicit) Explicit error returns Try-catch blocks
Forgetting to Handle Compile error Runtime crash Build succeeds, runtime issue NullPointerException common
Error Propagation ? operator (clean) Stack trace (implicit) if err != nil (verbose) Stack trace (implicit)
Type Safety Compile-time Runtime (loosely typed) Runtime checks Compile-time (verbose)
Performance No overhead GC pauses in exception handling Minimal overhead GC pauses in exceptions

Why Rust’s Approach is Superior

  1. Errors are values, not control flow - You can’t accidentally ignore errors
  2. Zero cost - No try-catch overhead or unwinding
  3. Composable - ? operator enables elegant chaining
  4. Explicit - Code clearly shows what can fail

Part 8: Resources & Further Reading

Official Documentation

Articles & Guides

Books

  • The Rust Programming Language by Steve Klabnik & Carol Nichols (Ch. 9)
  • Programming in Rust by Jim Blandy & Jason Orendorff (Ch. 7)
  • Rust in Action by Tim McNamara (Ch. 8)

Libraries & Tools

  • anyhow - Flexible error handling
  • thiserror - Structured error types
  • tracing - Structured logging
  • sentry - Error tracking & monitoring
  • eyre - Advanced error reporting

Part 9: Alternative Error Handling Approaches

The “Just Use String” Anti-pattern

Some projects use Result<T, String> everywhere. Avoid this:

  • Can’t match on specific errors
  • No context chaining
  • Difficult to test error conditions

The “Exception-Heavy” Anti-pattern

Overusing unwrap() or expect():

  • Crashes production services
  • Unhandled errors aren’t caught until runtime
  • Defeats Rust’s type system benefits

Go-Style Error Returns

Some frameworks use (T, error) instead of Result<T>:

  • Easier to accidentally ignore errors
  • Less idiomatic for Rust
  • Not leveraging Rust’s type system

Best approach for Rust: Use Result<T, E> with either thiserror (libraries) or anyhow (applications).


Part 10: Error Recovery Patterns

Not all errors are fatal. Here are recovery strategies:

// Pattern 1: Retry with exponential backoff
async fn fetch_with_retry<T, F>(
    mut f: F,
    max_retries: u32,
) -> Result<T, ApiError>
where
    F: std::marker::Unpin + Fn() -> futures::future::BoxFuture<'static, Result<T, ApiError>>,
{
    let mut retries = 0;
    loop {
        match f().await {
            Ok(value) => return Ok(value),
            Err(e) if retries < max_retries => {
                retries += 1;
                let backoff = std::time::Duration::from_millis(
                    u64::pow(2, retries) * 100
                );
                tokio::time::sleep(backoff).await;
                error!("Retry attempt {} after backoff", retries);
            }
            Err(e) => return Err(e),
        }
    }
}

// Pattern 2: Circuit breaker (fail fast if service is down)
struct CircuitBreaker {
    failures: u32,
    threshold: u32,
    state: CircuitState,
}

enum CircuitState {
    Closed,    // Normal operation
    Open,      // Service down, fail fast
    HalfOpen,  // Testing if service recovered
}

// Pattern 3: Graceful degradation
async fn get_user_profile(id: u64) -> Result<Profile> {
    match fetch_full_profile(id).await {
        Ok(profile) => Ok(profile),
        Err(e) => {
            warn!("Failed to fetch full profile: {}, using cached", e);
            fetch_cached_profile(id)
                .await
                .or_else(|_| {
                    warn!("Cache miss, returning basic profile");
                    Ok(Profile::default())
                })
        }
    }
}

Conclusion

Error handling in Rust web services is fundamentally different from most languages. Rust’s type system forces you to handle errors explicitly, preventing silent failures and runtime surprises. By combining:

  • Layered architecture (handlers โ†’ services โ†’ repositories)
  • Typed error definitions (thiserror or anyhow)
  • Structured logging (tracing)
  • HTTP mapping (IntoResponse implementations)
  • Graceful degradation (retry logic, circuit breakers)

You build web services that are not just correct, but resilient and debuggable at scale.

The key insight: Errors aren’t exceptionsโ€”they’re values. Treat them as first-class citizens in your application design, and your production services will thank you.


Comments