Rust error handling
Skill longzhi/rust-coding-standards/skills/rust-error-handling
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.From its SKILL.md
npx -y skills add longzhi/rust-coding-standards --skill rust-error-handlingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.4 KB, as published. Nobody here has run it
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
// Core aggregate: dedicated enum
// src/domain/order.rs
#[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,
}
// Auxiliary: module-level enum
// src/domain/errors.rs
#[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> {
// Query operations: include the key ID
let user = self.user_repo
.get(user_id)
.await
.context(format!("failed to get user {}", user_id))?;
// Business validation: return domain error directly
if user.balance < req.total {
return Err(PaymentError::InsufficientCredit {
required: req.total,
available: user.balance,
}.into());
}
// Subsequent operations: concise description (ID is already in the tracing span)
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;
}
// Domain errors implement this trait
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:
// Generic errors
"INTERNAL_ERROR"
"VALIDATION_ERROR"
"NOT_FOUND"
"UNAUTHORIZED"
"FORBIDDEN"
"RATE_LIMIT_EXCEEDED"
// Business errors (prefixed by aggregate)
"ORDER_NOT_FOUND"
"ORDER_INVALID_STATE"
"PAYMENT_INSUFFICIENT"
"QUOTA_EXCEEDED"
Error Mapping (IntoResponse)
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
// Attempt to downcast to known domain errors
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();
}
// Default: internal error
(
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_refchain 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 theanyhow::Error+ downcast approach with a top-level enum wrapper (e.g.,enum AppError { Order(OrderError), Payment(PaymentError), ... }) that implementsIntoResponsevia a single exhaustivematch. 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:
// GOOD
Err(anyhow!("failed to get user {}", user_id))
// BAD: contains sensitive fields
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
// Client errors: info level
tracing::info!(error = %error, "client error");
// Auth errors: warn level (potential attack)
tracing::warn!(error = %error, "auth error");
// Server errors: error level
tracing::error!(error = ?error, "infrastructure error");
Context via Instrumentation
// Use tracing::instrument to automatically capture context
#[tracing::instrument(skip(self))]
pub async fn process(&self, order_id: Uuid) -> Result<()> {
// order_id is already in the span; errors are automatically correlated
}
Forbidden Patterns
// NEVER: silently discard errors
let _ = some_fallible_operation();
// NEVER: panic on recoverable errors
if balance < required {
panic!("insufficient balance");
}
// NEVER: expose internal errors to clients
ApiError {
message: format!("Database error: {}", sqlx_error),
}
// NEVER: unwrap() in production code
let value = map.get("key").unwrap();
// NEVER: include sensitive information
Err(anyhow!("invalid token: {}", token))
Checklist
When handling errors, verify:
- domain/infra use thiserror, app uses anyhow
- Domain errors implement HttpError trait
- Error messages are in English
- Only IDs are included — no sensitive data
- Context on query operations includes the key ID
- Internal implementation details are not exposed to clients
- No unwrap/expect in production code
- Error codes use SCREAMING_SNAKE_CASE