Our Error Handling Adventure: Wrangling Results with Rust Crates!


Our Error Handling Adventure: Wrangling Results with Rust Crates!

Hey everyone! Have you ever been coding where you start with one way of doing things, and then, as your project grows, you realize, "Hmm, maybe there's a better way?" That was us recently with error handling in our Rust-based web app (built with the Axum framework!).

Act I: The Humble Beginnings - The All-in-One AppError

When I started the project, we defined our custom error enum, which we'll call AppError. It lived in our main.rs (or maybe a src/errors.rs that main.rs heavily relied on).

// Somewhere in the early stages...
pub enum AppError {
    DatabaseError(sqlx::Error),
    TemplateError(tera::Error),
    // ... and a few others
}

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::DatabaseError(err)
    }
}
// ... more From implementations ...

impl IntoResponse for AppError {
    // ... logic to turn AppError into an HTTP response ...
}

This worked great! sqlx errors? Bam, AppError::DatabaseError. Template rendering failed? AppError::TemplateError. Our best friend was the ? operator, neatly converting these specific errors into our AppError. Our Axum handlers would return Result<SomeSuccess, AppError>, and life was good.

Act II: The Plot Thickens - More Error Types Appear!

As our app grew, so did the variety of errors we needed to handle.

  • File I/O errors (std::io::Error)
  • Parsing integers (std::num::ParseIntError)
  • Multipart form processing errors (axum_typed_multipart::TypedMultipartError)
  • Configuration issues (dotenvy::Error)
  • Custom validation logic (often just String messages)
  • And even some domain-specific things like TezosError(String) for our blockchain interactions.

Our AppError enum started to get a bit... chunky.

// AppError started looking like this:
pub enum AppError {
    SqlxError(sqlx::Error),
    TeraError(tera::Error),
    IoError(std::io::Error), // New!
    DotenvyError(dotenvy::Error), // New!
    MultipartError(axum_typed_multipart::TypedMultipartError), // New!
    ImageProcessingError(String), // New!
    ValidationError(String), // New!
    Unauthorized, // New!
    NotFound, // New!
    Internal(String), // New!
    TezosError(String), // New!
    ParseInt(std::num::ParseIntError), // New!
}

And for every new error source, we'd add a new variant and a new impl From<ThatErrorType> for AppError. It was manageable, but we started to feel a little friction. What if a function deep in our business logic could return one of several different types of errors, none directly mapped to an existing AppError variant without some boilerplate? Or what if we just wanted to add context to an error before?

Act III: Enter the Heroes - anyhow and thiserror

This is where the Rust ecosystem shines. We decided to bring in two popular crates to help us level up our error-handling game:

  1. anyhow: This crate is fantastic for application-level error handling. It provides a single, simple error type (anyhow::Error) that can wrap any error that implements std::error::Error. The real magic is its Context trait, which lets you easily add descriptive messages as errors propagate up the call stack.

  2. thiserror: While anyhow is great for general error wrapping, thiserror helps you create custom, structured error types with minimal boilerplate. You can use it to define more specific error enums that derive std::error::Error (and Display, Debug) automatically.

Our strategy:

  • Consolidate AppError: Move our main AppError enum and all its From and IntoResponse implementations into a dedicated src/error.rs module. This keeps main.rs cleaner.
  • Embrace anyhow: In the lower levels of our application (business logic, utility functions), let functions return Result<T, anyhow::Error>. This gives us flexibility.
  • Bridge the Gap: Add a new variant to our AppError specifically for anyhow::Error.
// In our shiny new src/error.rs

// ... other imports ...
use serde_json::json; // For our JSON error responses
use std::fmt;

#[derive(Debug)] // thiserror could be used here for more complex variants!
pub enum AppError {
    SqlxError(sqlx::Error),
    TeraError(tera::Error),
    // ... all our previous variants ...
    Anyhow(anyhow::Error), // Our new hero!
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            // ... cases for other variants ...
            AppError::Anyhow(e) => write!(f, "{}", e), // Let anyhow's formatting shine
        }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            // ... cases for other variants, logging included ...
            AppError::Anyhow(e) => {
                tracing::error!("Anyhow error: {:?}", e); // Log the context-rich error
                (StatusCode::INTERNAL_SERVER_ERROR, format!("An internal error occurred: {}", e))
            }
        };
        (status, Json(json!({ "error": error_message }))).into_response()
    }
}

// ... other From implementations for specific error types ...

impl From<anyhow::Error> for AppError {
    fn from(err: anyhow::Error) -> Self {
        AppError::Anyhow(err) // Easy conversion!
    }
}

How it looks in practice:

Now, a function deep within our app might look like this:

use anyhow::{Context, Result}; // Note: Result here is anyhow::Result

fn process_complex_data(input: &str) -> Result<String> {
    let intermediate_data = some_fallible_operation(input)
        .context("Failed during the first fallible operation")?;

    let final_data = another_fallible_step(&intermediate_data)
        .with_context(|| format!("Failed processing intermediate data: {:?}", intermediate_data))?;
    Ok(final_data)
}

And in our Axum handler:

// In an Axum handler
async fn my_api_endpoint() -> Result<Json<MyData>, AppError> {
    // ...
    let result = process_complex_data("some_input")?; // The '?' uses From<anyhow::Error> for AppError
    // ...
    Ok(Json(MyData { /* ... */ }))
}

If another_fallible_step fails, anyhow wraps the original error and adds our context string. When it hits the ? in my_api_endpoint, it gets converted into an AppError::Anyhow variant. Our IntoResponse implementation for AppError then logs this rich error (thanks to anyhow's detailed debug output) and sends a generic error message to the client.

Why thiserror is still in our toolkit:

Even though we're using anyhow for general wrapping, thiserror is super handy if we want to define more structured errors within a specific domain of our application before they get wrapped by anyhow or converted into an AppError variant.

For example, we could have:

// Potentially in a module dealing with image processing
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ImageProcError {
    #[error("Unsupported image format: {0}")]
    UnsupportedFormat(String),
    #[error("Image dimensions exceed maximum allowed: {w}x{h}")]
    DimensionsTooLarge { w: u32, h: u32 },
    #[error(transparent)] // Wrap an underlying library error
    LibraryError(#[from] image::ImageError),
}

Then, a function could return Result<ProcessedImage, ImageProcError>. Suppose this needs to bubble up to an Axum handler. In that case, it can either be converted into a specific AppError variant (if we add one, e.g., AppError::Image(ImageProcError)) or just converted to anyhow::Error first and then to AppError::Anyhow.

The Journey Continues!

This new setup gives us:

  • Clarity: A dedicated error module.
  • Context: anyhow makes it trivial to add meaningful context to errors as they propagate.
  • Flexibility: Functions can return anyhow::Error without needing to know about all the specific AppError variants.
  • Structure: thiserror is there when we need to define more granular, domain-specific error types.

Our error handling is now more robust, easier to debug, and more pleasant to work with. It's a great example of how the Rust ecosystem provides powerful tools to manage complexity as a project evolves.