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:
-
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 implementsstd::error::Error
. The real magic is itsContext
trait, which lets you easily add descriptive messages as errors propagate up the call stack. -
thiserror
: Whileanyhow
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 derivestd::error::Error
(andDisplay
,Debug
) automatically.
Our strategy:
- Consolidate
AppError
: Move our mainAppError
enum and all itsFrom
andIntoResponse
implementations into a dedicatedsrc/error.rs
module. This keepsmain.rs
cleaner. - Embrace
anyhow
: In the lower levels of our application (business logic, utility functions), let functions returnResult<T, anyhow::Error>
. This gives us flexibility. - Bridge the Gap: Add a new variant to our
AppError
specifically foranyhow::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 specificAppError
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.