| name | rust-error-handling |
| description | Use when implementing error types, designing error hierarchies, mapping errors to HTTP responses, or reviewing error handling patterns in Rust code using thiserror and anyhow.
|
Error Handling Standard
Error Type Libraries
thiserror (domain/infra) + anyhow (app/service)
| Layer | Library | Rationale |
|---|
| domain | thiserror | Business errors must be explicit; callers need precise matching |
| infra | thiserror | Repository errors need explicit types |
| app | anyhow | Coordinate errors from multiple sources, quickly add context |
Error Layering
┌─────────────────────────────────────────────────────────┐
│ ApiError (web layer) │
│ HTTP status code mapping, JSON response format │
└─────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────┐
│ anyhow::Error (app layer) │
│ Business flow errors, combine multiple error sources │
└─────────────────────────────────────────────────────────┘
▲
┌────────────┴────────────┐
│ │
┌─────────────────────────┐ ┌─────────────────────────┐
│ DomainError │ │ InfraError │
│ Business rule violation│ │ External dependency fail│
└─────────────────────────┘ └─────────────────────────┘
Error Type Organization
Hybrid approach:
- Core aggregates (Order, User, Payment): define a dedicated error enum per aggregate
- Auxiliary objects: merge into a module-level error enum
- Rule of thumb: more than 5 error variants → dedicated enum
#[derive(Debug, thiserror::Error)]
pub enum OrderError {
#[error("order not found: {0}")]
NotFound(Uuid),
#[error("invalid state transition from {from} to {to}")]
InvalidStateTransition { from: String, to: String },
#[error("order already completed")]
AlreadyCompleted,
}
#[derive(Debug, thiserror::Error)]
pub enum PaymentError {
#[error("insufficient credit: required {required}, available {available}")]
InsufficientCredit { required: i64, available: i64 },
#[error("invalid amount: {0}")]
InvalidAmount(i64),
}
Domain Errors
Definition
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("order not found: {0}")]
NotFound(Uuid),
#[error("invalid order state transition from {from} to {to}")]
InvalidStateTransition { from: String, to: String },
#[error("order type {0} not supported")]
UnsupportedType(String),
}
Principles
- Only express business rule violations
- Never include technical details (e.g., database errors)
- Error messages should be developer-readable (English)
- Include diagnostic context (IDs only, no sensitive data)
Infrastructure Errors
Definition
#[derive(Debug, Error)]
pub enum RepoError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("record not found")]
NotFound,
#[error("duplicate key: {0}")]
DuplicateKey(String),
}
#[derive(Debug, Error)]
pub enum ExternalError {
#[error("external service error: {0}")]
ExternalService(String),
#[error("storage error: {0}")]
Storage(String),
}
Principles
- Wrap third-party library errors
- Provide error source information
- Use
#[from] to simplify conversions
Application Layer Errors (anyhow)
Usage
use anyhow::{Context, Result};
#[tracing::instrument(skip(self))]
pub async fn place_order(&self, user_id: Uuid, req: PlaceOrderRequest) -> Result<Order> {
let user = self.user_repo
.get(user_id)
.await
.context(format!("failed to get user {}", user_id))?;
if user.balance < req.total {
return Err(PaymentError::InsufficientCredit {
required: req.total,
available: user.balance,
}.into());
}
let order = self.order_repo
.create(&order)
.await
.context("failed to create order")?;
Ok(order)
}
Context Guidelines
| Scenario | Context Style |
|---|
| Query / fetch operations | Include key ID: "failed to get user {id}" |
| Subsequent operations | Concise description: "failed to update status" |
| External calls | Include target: "failed to call external service API" |
Web Layer Errors (ApiError)
HttpError Trait
pub trait HttpError {
fn status_code(&self) -> StatusCode;
fn error_code(&self) -> &'static str;
fn message(&self) -> String;
}
impl HttpError for OrderError {
fn status_code(&self) -> StatusCode {
match self {
OrderError::NotFound(_) => StatusCode::NOT_FOUND,
OrderError::InvalidStateTransition { .. } => StatusCode::UNPROCESSABLE_ENTITY,
OrderError::UnsupportedType(_) => StatusCode::BAD_REQUEST,
}
}
fn error_code(&self) -> &'static str {
match self {
OrderError::NotFound(_) => "ORDER_NOT_FOUND",
OrderError::InvalidStateTransition { .. } => "ORDER_INVALID_STATE",
OrderError::UnsupportedType(_) => "UNSUPPORTED_ORDER_TYPE",
}
}
fn message(&self) -> String {
self.to_string()
}
}
Error Response Format
#[derive(Serialize)]
struct ErrorResponse {
error: ErrorBody,
}
#[derive(Serialize)]
struct ErrorBody {
code: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<serde_json::Value>,
}
Error Code Convention
Use SCREAMING_SNAKE_CASE:
"INTERNAL_ERROR"
"VALIDATION_ERROR"
"NOT_FOUND"
"UNAUTHORIZED"
"FORBIDDEN"
"RATE_LIMIT_EXCEEDED"
"ORDER_NOT_FOUND"
"ORDER_INVALID_STATE"
"PAYMENT_INSUFFICIENT"
"QUOTA_EXCEEDED"
Error Mapping (IntoResponse)
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
if let Some(order_err) = self.source.downcast_ref::<OrderError>() {
return (
order_err.status_code(),
Json(ErrorResponse {
error: ErrorBody {
code: order_err.error_code().into(),
message: order_err.message(),
details: None,
},
}),
).into_response();
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: ErrorBody {
code: "INTERNAL_ERROR".into(),
message: "An internal error occurred".into(),
details: None,
},
}),
).into_response()
}
}
Trade-off note: The downcast_ref chain works well when you have a small number of domain error types. As the number of error types grows, this approach becomes hard to maintain and easy to forget a branch. At that point, consider replacing the anyhow::Error + downcast approach with a top-level enum wrapper (e.g., enum AppError { Order(OrderError), Payment(PaymentError), ... }) that implements IntoResponse via a single exhaustive match. The enum approach gives you compile-time exhaustiveness checking at the cost of slightly more boilerplate when adding new error types.
Sensitive Data Handling
Use IDs only — never include sensitive fields:
Err(anyhow!("failed to get user {}", user_id))
Err(anyhow!("failed to process user {}", user.email))
Err(anyhow!("invalid phone: {}", phone_number))
Sensitive fields include: name, email, phone, address, national ID, API keys, tokens.
Error Logging
Log Levels
tracing::info!(error = %error, "client error");
tracing::warn!(error = %error, "auth error");
tracing::error!(error = ?error, "infrastructure error");
Context via Instrumentation
#[tracing::instrument(skip(self))]
pub async fn process(&self, order_id: Uuid) -> Result<()> {
}
Forbidden Patterns
let _ = some_fallible_operation();
if balance < required {
panic!("insufficient balance");
}
ApiError {
message: format!("Database error: {}", sqlx_error),
}
let value = map.get("key").unwrap();
Err(anyhow!("invalid token: {}", token))
Checklist
When handling errors, verify: