| name | rust-error-handling |
| description | Production error patterns with thiserror and anyhow, including error classification, HTTP/gRPC protocol mappings, context chains, retry logic, and testing. Use when designing error types for libraries or applications, mapping errors to API responses, or implementing retry mechanisms. |
Rust Error Handling
Production error patterns with thiserror, anyhow, and protocol mappings
Version Context
- thiserror: 2.x (derive Error trait)
- anyhow: 1.x (dynamic errors)
- Standard Library: Result, Option
When to Use This Skill
- Designing error types for libraries
- Application-level error handling
- Mapping errors to HTTP/gRPC status codes
- Adding context to error chains
- Implementing retry logic
- Testing error paths
Library Errors with thiserror
use thiserror::Error;
/// Domain-specific error type for libraries
#[derive(Debug, Error)]
pub enum UserError {
#[error("user not found: {id}")]
NotFound { id: String },
#[error("invalid email format: {email}")]
InvalidEmail { email: String },
#[error("user already exists: {email}")]
AlreadyExists { email: String },
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("validation failed: {field}")]
Validation {
field: String,
#[source]
cause: ValidationError,
},
}
// Usage
async fn find_user(id: &str) -> Result<User, UserError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&pool)
.await?; // Automatically converts sqlx::Error to UserError
Ok(user)
}
Application Errors with anyhow
use anyhow::{Context, Result, bail, ensure};
/// Application-level errors with context
async fn process_order(order_id: &str) -> Result<()> {
let order = fetch_order(order_id)
.await
.context("failed to fetch order")?;
ensure!(order.status == OrderStatus::Pending, "order not in pending state");
let payment = process_payment(&order)
.await
.with_context(|| format!("payment failed for order {}", order_id))?;
if payment.amount != order.total {
bail!("payment amount mismatch: expected {}, got {}", order.total, payment.amount);
}
Ok(())
}
Error Classification
Layered Error Architecture
use thiserror::Error;
/// Infrastructure layer errors
#[derive(Debug, Error)]
pub enum InfraError {
#[error("database connection failed")]
DatabaseConnection(#[source] sqlx::Error),
#[error("cache operation failed")]
Cache(#[from] redis::RedisError),
#[error("message queue error")]
MessageQueue(#[source] Box<dyn std::error::Error + Send + Sync>),
}
/// Domain layer errors
#[derive(Debug, Error)]
pub enum DomainError {
#[error("business rule violated: {rule}")]
BusinessRule { rule: String, context: String },
#[error("entity not found: {entity_type} with id {id}")]
NotFound { entity_type: String, id: String },
#[error("duplicate entity: {entity_type}")]
Duplicate { entity_type: String },
}
/// Application layer errors (combines all layers)
#[derive(Debug, Error)]
pub enum AppError {
#[error("domain error: {0}")]
Domain(#[from] DomainError),
#[error("infrastructure error: {0}")]
Infrastructure(#[from] InfraError),
#[error("validation error: {0}")]
Validation(#[from] validator::ValidationErrors),
#[error("authentication failed")]
AuthenticationFailed,
#[error("authorization failed: insufficient permissions")]
AuthorizationFailed,
}
Protocol Mappings
HTTP Status Mapping (Axum)
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
Json,
};
use serde_json::json;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_type, message) = match self {
AppError::Domain(DomainError::NotFound { .. }) => {
(StatusCode::NOT_FOUND, "not_found", self.to_string())
}
AppError::Domain(DomainError::Duplicate { .. }) => {
(StatusCode::CONFLICT, "conflict", self.to_string())
}
AppError::Domain(DomainError::BusinessRule { .. }) => {
(StatusCode::UNPROCESSABLE_ENTITY, "business_rule_violation", self.to_string())
}
AppError::Validation(_) => {
(StatusCode::BAD_REQUEST, "validation_error", self.to_string())
}
AppError::AuthenticationFailed => {
(StatusCode::UNAUTHORIZED, "authentication_failed", "Authentication required".to_string())
}
AppError::AuthorizationFailed => {
(StatusCode::FORBIDDEN, "authorization_failed", "Insufficient permissions".to_string())
}
AppError::Infrastructure(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Internal server error".to_string())
}
};
let body = Json(json!({
"error": {
"type": error_type,
"message": message,
}
}));
(status, body).into_response()
}
}
gRPC Status Mapping (tonic)
use tonic::{Status, Code};
impl From<AppError> for Status {
fn from(err: AppError) -> Self {
match err {
AppError::Domain(DomainError::NotFound { .. }) => {
Status::new(Code::NotFound, err.to_string())
}
AppError::Domain(DomainError::BusinessRule { .. }) => {
Status::new(Code::FailedPrecondition, err.to_string())
}
AppError::Validation(_) => {
Status::new(Code::InvalidArgument, err.to_string())
}
AppError::AuthenticationFailed => {
Status::new(Code::Unauthenticated, "Authentication required")
}
AppError::AuthorizationFailed => {
Status::new(Code::PermissionDenied, "Insufficient permissions")
}
AppError::Infrastructure(_) => {
Status::new(Code::Internal, "Internal server error")
}
_ => Status::new(Code::Unknown, "Unknown error"),
}
}
}
Error Context
Adding Context with anyhow
use anyhow::{Context, Result};
async fn load_user_profile(user_id: &str) -> Result<UserProfile> {
let user = fetch_user(user_id)
.await
.context("failed to fetch user")?;
let profile = fetch_profile(user_id)
.await
.with_context(|| format!("failed to fetch profile for user {}", user_id))?;
let orders = fetch_orders(user_id)
.await
.context("failed to fetch user orders")?;
Ok(UserProfile {
user,
profile,
order_count: orders.len(),
})
}
Error Patterns
Early Return with ?
async fn create_order(request: CreateOrderRequest) -> Result<Order, OrderError> {
// Validate early
let customer = find_customer(&request.customer_id).await?;
let product = find_product(&request.product_id).await?;
// Business validation
if product.stock < request.quantity {
return Err(OrderError::InsufficientStock {
product_id: request.product_id,
available: product.stock,
requested: request.quantity,
});
}
// Create order
let order = Order::new(customer, product, request.quantity);
save_order(&order).await?;
Ok(order)
}
Option to Result Conversion
async fn get_user_email(user_id: &str) -> Result<String, UserError> {
let user = find_user(user_id).await?;
// Convert Option to Result
user.email.ok_or_else(|| UserError::EmailNotSet {
user_id: user_id.to_string(),
})
}
Retry Logic
Simple Retry with Exponential Backoff
use tokio::time::{sleep, Duration};
async fn retry_with_backoff<F, Fut, T, E>(
mut operation: F,
max_attempts: u32,
) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut attempts = 0;
let base_delay = Duration::from_millis(100);
loop {
attempts += 1;
match operation().await {
Ok(result) => return Ok(result),
Err(e) if attempts >= max_attempts => return Err(e),
Err(e) => {
let delay = base_delay * 2_u32.pow(attempts - 1);
eprintln!("Attempt {} failed, waiting {:?}", attempts, delay);
sleep(delay).await;
}
}
}
}
// Usage
let result = retry_with_backoff(
|| async { fetch_user("user_123").await },
3,
).await?;
Testing Errors
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_conversion() {
let db_error = sqlx::Error::RowNotFound;
let user_error: UserError = db_error.into();
assert!(matches!(user_error, UserError::Database(_)));
}
#[tokio::test]
async fn test_not_found_error() {
let result = find_user("nonexistent").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), UserError::NotFound { .. }));
}
#[test]
fn test_error_display() {
let error = UserError::InvalidEmail {
email: "invalid".to_string(),
};
assert_eq!(error.to_string(), "invalid email format: invalid");
}
}
Best Practices
- Libraries use thiserror: Concrete error types with
#[derive(Error)] - Applications use anyhow: Dynamic errors with context chains
- Classify errors: Validation, Business Logic, Infrastructure
- Map to protocols: HTTP status codes, gRPC codes
- Add context: Use
.context()and.with_context() - Fail fast: Validate early, return errors immediately
- Don't panic in libraries: Return
Resultinstead - Preserve error chains: Use
#[source]and#[from] - Test error paths: Verify error types and messages
Common Dependencies
[dependencies]
thiserror = "2"
anyhow = "1"