Using thiserror and anyhow effectively
Real applications have errors from multiple sources: I/O, parsing, network, database. Error composition helps you:
fn process_file() -> Result<Data, ???> { // What error type?
let contents = std::fs::read_to_string("file.txt")?; // io::Error
let data = serde_json::from_str(&contents)?; // serde_json::Error
Ok(data)
}
Two Solutions:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataProcessError {
#[error("Failed to read file: {path}")]
FileRead {
path: String,
#[source]
source: std::io::Error,
},
#[error("JSON parse error at line {line}")]
JsonParse {
line: usize,
#[source]
source: serde_json::Error,
},
#[error("Invalid data: {0}")]
Validation(String),
#[error(transparent)]
Other(#[from] Box<dyn std::error::Error + Send + Sync>),
}
// Usage
fn process_file(path: &str) -> Result<Data, DataProcessError> {
let contents = std::fs::read_to_string(path)
.map_err(|e| DataProcessError::FileRead {
path: path.to_string(),
source: e,
})?;
let data: Data = serde_json::from_str(&contents)
.map_err(|e| DataProcessError::JsonParse {
line: e.line(),
source: e,
})?;
if !data.is_valid() {
return Err(DataProcessError::Validation("Invalid checksum".into()));
}
Ok(data)
}
| Attribute | Purpose | Example |
|-----------|---------|---------|
| #[error("...")] | Display message | #[error("Not found: {id}")] |
| #[source] | Mark error source | #[source] source: io::Error |
| #[from] | Auto From impl | #[from] io::Error |
| #[transparent] | Forward Display/Error | #[error(transparent)] |
use anyhow::{Context, Result};
fn process_user_data(user_id: u64) -> Result<()> {
// Automatically converts any error type
let user_file = format!("users/{}.json", user_id);
let contents = std::fs::read_to_string(&user_file)
.context(format!("Failed to read user file: {}", user_file))?;
let user: User = serde_json::from_str(&contents)
.context("Failed to parse user JSON")?;
save_to_database(&user)
.context(format!("Failed to save user {} to database", user_id))?;
Ok(())
}
// Error handling
fn main() {
if let Err(e) = process_user_data(123) {
eprintln!("Error: {:?}", e);
// Print error chain
for cause in e.chain() {
eprintln!(" Caused by: {}", cause);
}
}
}
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Redis cache error: {0}")]
Cache(#[from] redis::RedisError),
#[error("JSON serialization error")]
Serialization(#[from] serde_json::Error),
#[error("Authentication failed: {reason}")]
AuthFailed { reason: String },
#[error("Resource not found: {resource_type} with id {id}")]
NotFound { resource_type: String, id: String },
#[error("Rate limit exceeded: {retry_after}s")]
RateLimitExceeded { retry_after: u64 },
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
}
impl ApiError {
/// Convert to HTTP status code
pub fn status_code(&self) -> u16 {
match self {
ApiError::NotFound { .. } => 404,
ApiError::AuthFailed { .. } => 401,
ApiError::RateLimitExceeded { .. } => 429,
ApiError::Validation(_) => 400,
ApiError::Database(_) | ApiError::Cache(_) | ApiError::Internal(_) => 500,
ApiError::Serialization(_) => 500,
}
}
/// Should this error be logged?
pub fn should_log(&self) -> bool {
matches!(self, ApiError::Database(_) | ApiError::Internal(_))
}
/// Client-safe error message
pub fn client_message(&self) -> String {
match self {
ApiError::AuthFailed { reason } => reason.clone(),
ApiError::NotFound { resource_type, .. } => {
format!("{} not found", resource_type)
}
ApiError::Validation(msg) => msg.clone(),
ApiError::RateLimitExceeded { retry_after } => {
format!("Rate limit exceeded. Retry after {}s", retry_after)
}
// Don't expose internal errors to client
_ => "Internal server error".to_string(),
}
}
}
// Usage in HTTP handler
use anyhow::Context;
async fn get_user_handler(user_id: i64) -> Result<User, ApiError> {
// Database query - auto-converts sqlx::Error to ApiError
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&pool)
.await?
.ok_or_else(|| ApiError::NotFound {
resource_type: "User".to_string(),
id: user_id.to_string(),
})?;
// Cache result - auto-converts RedisError to ApiError
let serialized = serde_json::to_string(&user)?;
redis_conn.set_ex(format!("user:{}", user_id), serialized, 3600).await?;
Ok(user)
}
use anyhow::{Context, Result, bail, ensure};
fn main() {
if let Err(e) = run() {
eprintln!("Error: {:?}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let config_path = std::env::args()
.nth(1)
.context("Missing config file argument. Usage: app <config.toml>")?;
// Load config
let config = load_config(&config_path)
.context(format!("Failed to load config from {}", config_path))?;
// Validate config
ensure!(config.port > 0 && config.port < 65536, "Port must be between 1-65535");
ensure!(!config.api_key.is_empty(), "API key cannot be empty");
// Connect to database
let mut conn = connect_database(&config.database_url)
.context("Failed to connect to database")?;
// Run migrations
run_migrations(&mut conn)
.context("Failed to run database migrations")?;
// Start server
start_server(&config)
.context("Failed to start server")?;
Ok(())
}
fn load_config(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
fn connect_database(url: &str) -> Result<Connection> {
Connection::connect(url)
.context("Database connection failed")
}
fn run_migrations(conn: &mut Connection) -> Result<()> {
for migration in MIGRATIONS {
conn.execute(migration)
.with_context(|| format!("Migration failed: {}", migration.name))?;
}
Ok(())
}
// Custom error for specific cases
fn validate_email(email: &str) -> Result<()> {
if !email.contains('@') {
bail!("Invalid email format: {}", email);
}
Ok(())
}
use thiserror::Error;
use serde::{Serialize, Deserialize};
#[derive(Error, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "details")]
pub enum ServiceError {
#[error("Upstream service '{service}' returned error: {message}")]
UpstreamError {
service: String,
status_code: u16,
message: String,
},
#[error("Service '{service}' timeout after {timeout_ms}ms")]
Timeout {
service: String,
timeout_ms: u64,
},
#[error("Circuit breaker open for service '{service}'")]
CircuitBreakerOpen {
service: String,
failure_count: usize,
},
#[error("Service '{service}' unavailable")]
ServiceUnavailable {
service: String,
#[serde(skip)]
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
}
// Service client with error composition
pub struct ServiceClient {
http_client: reqwest::Client,
service_url: String,
}
impl ServiceClient {
pub async fn call_service(&self, payload: &Request) -> Result<Response, ServiceError> {
let response = self.http_client
.post(&self.service_url)
.json(payload)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
ServiceError::Timeout {
service: "user-service".to_string(),
timeout_ms: 5000,
}
} else {
ServiceError::ServiceUnavailable {
service: "user-service".to_string(),
source: Some(Box::new(e)),
}
}
})?;
if !response.status().is_success() {
return Err(ServiceError::UpstreamError {
service: "user-service".to_string(),
status_code: response.status().as_u16(),
message: response.text().await.unwrap_or_default(),
});
}
Ok(response.json().await.unwrap())
}
}
impl From<std::io::Error> for MyError {
fn from(error: std::io::Error) -> Self {
MyError::Io { source: error }
}
}
// Now ? operator auto-converts
fn read_file() -> Result<String, MyError> {
let contents = std::fs::read_to_string("file.txt")?; // Auto-converts
Ok(contents)
}
#[derive(Error, Debug)]
pub enum MyError {
#[error("IO error")]
Io(#[from] std::io::Error), // Generates From impl automatically
#[error("Parse error")]
Parse(#[from] serde_json::Error), // Another From impl
}
fn read_config() -> Result<Config, MyError> {
std::fs::read_to_string("config.toml")
.map_err(|e| MyError::ConfigRead {
path: "config.toml".into(),
source: e,
})?;
// ...
}
use anyhow::Context;
// Add context to any error
result.context("High-level description")?;
// Add formatted context
result.with_context(|| format!("Processing user {}", user_id))?;
use anyhow::Result;
fn handle_error(e: anyhow::Error) {
// Check for specific error type
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
eprintln!("IO error: {}", io_err);
}
}
use anyhow::{bail, ensure};
fn process(value: i32) -> Result<()> {
// Early return with error
if value < 0 {
bail!("Value cannot be negative: {}", value);
}
// Assert-like error
ensure!(value < 100, "Value too large: {}", value);
Ok(())
}
| Approach | Allocation | Size | Performance |
|----------|-----------|------|-------------|
| Result | Stack | Small | Fastest |
| Result | Heap | Pointer | Fast |
| anyhow::Result | Heap | Pointer + vtable | Fast |
thiserror (concrete types)anyhow (flexibility)// BAD: Discards original error
result.map_err(|_| MyError::Generic)?;
// GOOD: Preserve error chain
result.map_err(|e| MyError::Wrapped { source: e })?;
// BAD: Library with anyhow (loses type info)
pub fn lib_function() -> anyhow::Result<()> { ... }
// GOOD: Library with concrete error type
pub fn lib_function() -> Result<(), LibError> { ... }
// BAD: Unclear error origin
std::fs::read_to_string(path)?;
// GOOD: Clear context
std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config from {}", path))?;
Build error type for app that uses database, cache, and external API.
Hints:Create CLI tool with config loading, file processing, and network calls.
Hints:Implement retry logic with specific error handling.
Hints:Uses thiserror for structured errors in async runtime.
View on GitHubCLI tool using anyhow for application errors.
View on GitHubWeb framework with thiserror for HTTP errors.
View on GitHubRun this code in the official Rust Playground