Chapter 2: Error Handling & Type-Safe Errors
Introduction
Error handling is one of Rust’s greatest strengths. Unlike languages with exceptions, Rust uses the type system to enforce error handling at compile time. Pierre takes this further with a zero-tolerance policy on ad-hoc errors.
CLAUDE.md Directive (critical):
Never use
anyhow::anyhow!()in production codeUse structured error types exclusively:
AppError,DatabaseError,ProviderError
This chapter teaches you why this matters and how Pierre implements production-grade error handling.
The Problem with Anyhow
The anyhow crate is popular for quick prototyping, but has serious issues in production code.
Anyhow Example (Anti-pattern)
#![allow(unused)]
fn main() {
// DON'T DO THIS - Loses type information
use anyhow::anyhow;
fn fetch_user(id: &str) -> anyhow::Result<User> {
if id.is_empty() {
return Err(anyhow!("User ID cannot be empty")); // ❌ Type-erased error
}
let user = database.get(id)
.ok_or_else(|| anyhow!("User not found"))?; // ❌ No structure
Ok(user)
}
}
Problems:
- Type erasure: All errors become
anyhow::Error(opaque box) - No pattern matching: Can’t handle different error types differently
- No programmatic access: Error details are just strings
- Poor API: Callers can’t know what errors to expect
- No HTTP mapping: How do you convert “User not found” to status code?
Structured Error Example (Correct)
Source: src/database/errors.rs:11-20
#![allow(unused)]
fn main() {
// DO THIS - Type-safe, structured errors
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Entity not found: {entity_type} with id '{entity_id}'")]
NotFound {
entity_type: &'static str,
entity_id: String,
},
// ... more variants
}
fn fetch_user(id: &str) -> Result<User, DatabaseError> {
if id.is_empty() {
return Err(DatabaseError::NotFound {
entity_type: "user",
entity_id: String::new(),
});
}
// Callers can pattern match on this specific error
database.get(id)
.ok_or_else(|| DatabaseError::NotFound {
entity_type: "user",
entity_id: id.to_string(),
})
}
}
Benefits:
- ✅ Type safety: Errors are concrete types
- ✅ Pattern matching: Can handle
NotFoundvsConnectionErrordifferently - ✅ Programmatic access: Extract
entity_idfrom error - ✅ Clear API: Callers know what to expect
- ✅ HTTP mapping: Easy to convert to status codes
Pierre’s Error Hierarchy
Pierre uses a three-tier error hierarchy:
AppError (src/errors.rs) ← HTTP-level errors
↓ wraps
├── DatabaseError (src/database/errors.rs) ← Database operations
├── ProviderError (src/providers/errors.rs) ← External API calls
└── ProtocolError (src/protocols/...) ← Protocol-specific errors
Design principle: Errors are defined close to their domain, then converted to AppError at API boundaries.
Thiserror: Derive Macro for Errors
The thiserror crate provides a derive macro that auto-implements std::error::Error and Display.
Basic Thiserror Usage
Source: src/database/errors.rs:11-56
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
/// Entity not found in database
#[error("Entity not found: {entity_type} with id '{entity_id}'")]
NotFound {
entity_type: &'static str,
entity_id: String,
},
/// Cross-tenant access attempt detected
#[error("Tenant isolation violation: attempted to access {entity_type} '{entity_id}' from tenant '{requested_tenant}' but it belongs to tenant '{actual_tenant}'")]
TenantIsolationViolation {
entity_type: &'static str,
entity_id: String,
requested_tenant: String,
actual_tenant: String,
},
/// Encryption operation failed
#[error("Encryption failed: {context}")]
EncryptionFailed {
context: String,
},
/// Decryption operation failed
#[error("Decryption failed: {context}")]
DecryptionFailed {
context: String,
},
/// Database constraint violation
#[error("Constraint violation: {constraint} - {details}")]
ConstraintViolation {
constraint: String,
details: String,
},
}
}
Rust Idioms Explained:
-
#[derive(Error, Debug)]Error: thiserror’s derive macroDebug: Required bystd::error::Errortrait- Auto-implements
Displayusing#[error(...)]attributes
-
#[error("...")]format strings- Defines the
Displayimplementation - Use
{field_name}to interpolate struct fields - Same syntax as
format!()macro
- Defines the
-
Enum variants with fields
- Struct-like variants:
NotFound { entity_type, entity_id } - Tuple variants:
ConnectionError(String) - Unit variants:
Timeout(no fields)
- Struct-like variants:
-
Documentation comments
///- Document each variant’s purpose
- Appears in IDE tooltips and
cargo doc
Generated code (what thiserror creates):
#![allow(unused)]
fn main() {
// thiserror automatically generates this:
impl std::fmt::Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::NotFound { entity_type, entity_id } => {
write!(f, "Entity not found: {} with id '{}'", entity_type, entity_id)
}
// ... other variants
}
}
}
impl std::error::Error for DatabaseError {}
}
Reference: thiserror documentation
Error Variant Design Patterns
Pierre uses several patterns for error variants.
Pattern 1: Struct-Like Variants with Context
Source: src/providers/errors.rs:13-23
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum ProviderError {
/// Provider API is unavailable or returning errors
#[error("Provider {provider} API error: {status_code} - {message}")]
ApiError {
provider: String,
status_code: u16,
message: String,
retryable: bool, // ← Extra context for retry logic
},
}
}
Use when: You need multiple pieces of context (who, what, why)
Pattern matching:
#![allow(unused)]
fn main() {
match error {
ProviderError::ApiError { status_code: 429, provider, retry_after_secs, .. } => {
println!("Rate limited by {}, retry in {} seconds", provider, retry_after_secs);
}
ProviderError::ApiError { status_code, .. } if status_code >= 500 => {
println!("Server error, retry with backoff");
}
_ => println!("Non-retryable error"),
}
}
Pattern 2: Tuple Variants for Simple Errors
Source: src/database/errors.rs:57-59
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum DatabaseError {
/// Database connection error
#[error("Database connection error: {0}")]
ConnectionError(String),
// More examples:
/// Database query error
#[error("Query execution error: {context}")]
QueryError { context: String },
}
}
Use when: Single piece of context is sufficient
Creating:
#![allow(unused)]
fn main() {
return Err(DatabaseError::ConnectionError(
"Failed to connect to postgres://localhost:5432".to_string()
));
}
Pattern 3: Unit Variants for Simple Cases
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Configuration file not found")]
NotFound,
#[error("Permission denied accessing configuration")]
PermissionDenied,
}
}
Use when: Error needs no additional context
Pattern 4: Wrapping External Errors
Source: src/database/errors.rs:86-96
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum DatabaseError {
/// Underlying SQLx error
#[error("Database error: {0}")]
Sqlx(#[from] sqlx::Error), // ← Automatic conversion
/// Serialization/deserialization error
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
/// UUID parsing error
#[error("Invalid UUID: {0}")]
InvalidUuid(#[from] uuid::Error),
// Note: No blanket anyhow::Error conversion - all errors are structured!
}
}
Rust Idioms Explained:
-
#[from]attribute- Auto-generates
From<ExternalError> for MyError - Enables
?operator to auto-convert errors
- Auto-generates
-
Generated
Fromimplementation:
#![allow(unused)]
fn main() {
// thiserror generates this:
impl From<sqlx::Error> for DatabaseError {
fn from(err: sqlx::Error) -> Self {
Self::Sqlx(err)
}
}
}
- Usage with
?operator:
#![allow(unused)]
fn main() {
fn get_user(id: &str) -> Result<User, DatabaseError> {
// sqlx::Error automatically converts to DatabaseError::Sqlx
let row = sqlx::query!("SELECT * FROM users WHERE id = ?", id)
.fetch_one(&pool)
.await?; // ← Auto-conversion happens here
Ok(user_from_row(row))
}
}
Reference: Rust Book - The ? Operator
Error Code System
Pierre maps domain errors to HTTP status codes and error codes.
Source: src/errors.rs:41-100
#![allow(unused)]
fn main() {
/// Standard error codes used throughout the application
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
// Authentication & Authorization
AuthRequired, // 401
AuthInvalid, // 401
AuthExpired, // 403
AuthMalformed, // 403
PermissionDenied, // 403
// Rate Limiting
RateLimitExceeded, // 429
QuotaExceeded, // 429
// Validation
InvalidInput, // 400
MissingRequiredField,// 400
InvalidFormat, // 400
ValueOutOfRange, // 400
// Resource Management
ResourceNotFound, // 404
ResourceAlreadyExists, // 409
ResourceLocked, // 409
ResourceUnavailable, // 503
// External Services
ExternalServiceError, // 502
ExternalServiceUnavailable, // 502
ExternalAuthFailed, // 503
ExternalRateLimited, // 503
// Internal Errors
InternalError, // 500
DatabaseError, // 500
StorageError, // 500
SerializationError, // 500
}
}
HTTP Status Code Mapping
Source: src/errors.rs:87-138
#![allow(unused)]
fn main() {
impl ErrorCode {
/// Get the HTTP status code for this error
#[must_use]
pub const fn http_status(self) -> u16 {
match self {
// 400 Bad Request
Self::InvalidInput
| Self::MissingRequiredField
| Self::InvalidFormat
| Self::ValueOutOfRange => crate::constants::http_status::BAD_REQUEST,
// 401 Unauthorized - Authentication issues
Self::AuthRequired | Self::AuthInvalid =>
crate::constants::http_status::UNAUTHORIZED,
// 403 Forbidden - Authorization issues
Self::AuthExpired | Self::AuthMalformed | Self::PermissionDenied =>
crate::constants::http_status::FORBIDDEN,
// 404 Not Found
Self::ResourceNotFound => crate::constants::http_status::NOT_FOUND,
// 409 Conflict
Self::ResourceAlreadyExists | Self::ResourceLocked =>
crate::constants::http_status::CONFLICT,
// 429 Too Many Requests
Self::RateLimitExceeded | Self::QuotaExceeded =>
crate::constants::http_status::TOO_MANY_REQUESTS,
// 500 Internal Server Error
Self::InternalError
| Self::DatabaseError
| Self::StorageError
| Self::SerializationError =>
crate::constants::http_status::INTERNAL_SERVER_ERROR,
}
}
}
}
Rust Idioms Explained:
-
#[must_use]attribute- Compiler warning if return value is ignored
- Prevents silent errors:
error.http_status();(unused) is a warning
-
pub const fn- Const function- Can be evaluated at compile time
- No heap allocations allowed
- Perfect for simple mappings like this
-
Pattern matching with
|(OR patterns)Self::InvalidInput | Self::MissingRequiredField= match either variant- Cleaner than nested
ifstatements
Reference: Rust Reference - Const Functions
User-Friendly Descriptions
Source: src/errors.rs:140-172
#![allow(unused)]
fn main() {
impl ErrorCode {
/// Get a user-friendly description of this error
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::AuthRequired =>
"Authentication is required to access this resource",
Self::AuthInvalid =>
"The provided authentication credentials are invalid",
Self::RateLimitExceeded =>
"Rate limit exceeded. Please slow down your requests",
Self::ResourceNotFound =>
"The requested resource was not found",
// ... more descriptions
}
}
}
}
Return type: &'static str - String slice with 'static lifetime
- Lives for entire program duration
- No heap allocation
- Stored in binary’s read-only data section
Error Conversion with From/Into
Rust’s ? operator relies on From trait implementations for automatic error conversion.
Automatic from with #[from]
Source: src/database/errors.rs:86-96
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Database error: {0}")]
Sqlx(#[from] sqlx::Error), // ← Generates From impl automatically
}
}
Error Propagation Chain
#![allow(unused)]
fn main() {
// Example: Error propagates through multiple layers
// Layer 1: Database operation
async fn get_user_from_db(id: &str) -> Result<User, DatabaseError> {
let row = sqlx::query!("SELECT * FROM users WHERE id = ?", id)
.fetch_one(&pool)
.await?; // sqlx::Error → DatabaseError::Sqlx
Ok(user_from_row(row))
}
// Layer 2: Service operation
async fn fetch_user(id: &str) -> Result<User, AppError> {
let user = get_user_from_db(id)
.await?; // DatabaseError → AppError::Database
Ok(user)
}
// Layer 3: HTTP handler
async fn user_endpoint(id: String) -> impl IntoResponse {
match fetch_user(&id).await {
Ok(user) => (StatusCode::OK, Json(user)),
Err(app_error) => {
let status = app_error.http_status();
let body = app_error.to_json();
(status, Json(body))
}
}
}
}
Rust Idioms Explained:
?operator propagation- Converts error types automatically via
Fromimplementations - Early return on
Errvariant - Equivalent to manual
match:
- Converts error types automatically via
#![allow(unused)]
fn main() {
// These are equivalent:
let user = get_user_from_db(id).await?;
// Desugared version:
let user = match get_user_from_db(id).await {
Ok(val) => val,
Err(e) => return Err(e.into()), // ← Calls From::from
};
}
- Error wrapping hierarchy
- Low-level errors (sqlx::Error) → Domain errors (DatabaseError)
- Domain errors → Application errors (AppError)
- Application errors → HTTP responses
Reference: Rust Book - Error Propagation
Provider Error with Retry Logic
Provider errors include retry information for transient failures.
Source: src/providers/errors.rs:10-101
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum ProviderError {
/// Provider API is unavailable or returning errors
#[error("Provider {provider} API error: {status_code} - {message}")]
ApiError {
provider: String,
status_code: u16,
message: String,
retryable: bool,
},
/// Rate limit exceeded with retry information
#[error("Rate limit exceeded for {provider}: retry after {retry_after_secs} seconds")]
RateLimitExceeded {
provider: String,
retry_after_secs: u64,
limit_type: String,
},
// ... more variants
}
impl ProviderError {
/// Check if error is retryable
#[must_use]
pub const fn is_retryable(&self) -> bool {
match self {
Self::ApiError { retryable, .. } => *retryable,
Self::RateLimitExceeded { .. } | Self::NetworkError(_) => true,
Self::AuthenticationFailed { .. }
| Self::NotFound { .. }
| Self::InvalidData { .. } => false,
}
}
/// Get retry delay in seconds if applicable
#[must_use]
pub const fn retry_after_secs(&self) -> Option<u64> {
match self {
Self::RateLimitExceeded { retry_after_secs, .. } =>
Some(*retry_after_secs),
_ => None,
}
}
}
}
Usage in retry logic:
#![allow(unused)]
fn main() {
async fn fetch_with_retry(url: &str) -> Result<Response, ProviderError> {
let mut attempts = 0;
loop {
match fetch(url).await {
Ok(response) => return Ok(response),
Err(e) if e.is_retryable() && attempts < 3 => {
attempts += 1;
if let Some(delay) = e.retry_after_secs() {
tokio::time::sleep(Duration::from_secs(delay)).await;
} else {
// Exponential backoff: 2^attempts seconds
let delay = 2_u64.pow(attempts);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
}
Err(e) => return Err(e), // Non-retryable or max attempts
}
}
}
}
Rust Idioms Explained:
-
Match guards
if e.is_retryable()- Add conditions to match arms
Err(e) if e.is_retryable()only matches retryable errors
-
const fnmethods- Methods callable in const contexts
- No allocations, pure logic only
-
Exponential backoff calculation
2_u64.pow(attempts)calculates 2^n- Underscores in numbers (
2_u64) are for readability
Result Type Aliases
Pierre defines type aliases for cleaner signatures.
Source: src/database/errors.rs:143
#![allow(unused)]
fn main() {
/// Result type for database operations
pub type DatabaseResult<T> = Result<T, DatabaseError>;
}
Source: src/providers/errors.rs:200
#![allow(unused)]
fn main() {
/// Result type for provider operations
pub type ProviderResult<T> = Result<T, ProviderError>;
}
Usage:
#![allow(unused)]
fn main() {
// Without alias
async fn get_user(id: &str) -> Result<User, DatabaseError> { ... }
// With alias (cleaner)
async fn get_user(id: &str) -> DatabaseResult<User> { ... }
}
Rust Idiom: Type aliases reduce boilerplate for commonly-used Result types.
Reference: Rust Book - Type Aliases
Error Handling Patterns
Pattern 1: Map_err for Context
#![allow(unused)]
fn main() {
use crate::database::DatabaseError;
async fn load_config(path: &str) -> DatabaseResult<Config> {
let contents = tokio::fs::read_to_string(path)
.await
.map_err(|e| DatabaseError::InvalidData {
field: "config_file".to_string(),
reason: format!("Failed to read config from {}: {}", path, e),
})?;
let config: Config = serde_json::from_str(&contents)
.map_err(|e| DatabaseError::SerializationError(e))?;
Ok(config)
}
}
Rust Idiom: .map_err(|e| ...) transforms one error type to another, adding context.
Pattern 2: Ok_or for Option → Result
#![allow(unused)]
fn main() {
fn find_user_by_email(email: &str) -> DatabaseResult<User> {
users_cache.get(email)
.ok_or_else(|| DatabaseError::NotFound {
entity_type: "user",
entity_id: email.to_string(),
})
}
}
Rust Idiom: Convert Option<T> to Result<T, E> with custom error.
Pattern 3: And_then for Chaining
#![allow(unused)]
fn main() {
async fn get_user_and_validate(id: &str) -> DatabaseResult<User> {
get_user_from_db(id)
.await
.and_then(|user| {
if user.is_active {
Ok(user)
} else {
Err(DatabaseError::InvalidData {
field: "is_active".to_string(),
reason: "User account is inactive".to_string(),
})
}
})
}
}
Rust Idiom: .and_then() chains operations that can fail, flattening nested Results.
Reference: Rust Book - Result Methods
Diagram: Error Flow
┌─────────────────────────────────────────────────────────────┐
│ HTTP Request │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ HTTP Handler (Axum) │
│ Returns: Result<T, AppError>│
└─────────────┬───────────────┘
│ ?
▼
┌─────────────────────────────┐
│ Service Layer │
│ Returns: Result<T, AppError>│
└─────────────┬───────────────┘
│ ?
┌─────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Database Layer │ │Provider Layer│ │ Other Layers │
│DatabaseError │ │ProviderError │ │ProtocolError │
└────────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ From impl │ From impl │ From impl
└────────────────┼────────────────┘
│
▼
┌─────────────────────────────┐
│ AppError │
│ (unified application error)│
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ HTTP Response │
│ Status Code + JSON Body │
└─────────────────────────────┘
Flow explanation:
- Request enters HTTP handler
- Handler calls service layer (propagates with
?) - Service calls database/provider/protocol layers (propagates with
?) - Domain errors automatically convert to
AppErrorviaFromimplementations AppErrorconverts to HTTP response (status code + JSON body)
Rust Idioms Summary
| Idiom | Purpose | Example Location |
|---|---|---|
thiserror::Error derive | Auto-implement Error trait | src/database/errors.rs:10 |
#[error("...")] attribute | Define Display format | src/database/errors.rs:13 |
#[from] attribute | Auto-generate From impl | src/database/errors.rs:88 |
| Enum variants with fields | Structured error context | src/errors.rs:19-85 |
#[must_use] attribute | Warn on unused return | src/errors.rs:89 |
pub const fn | Compile-time functions | src/errors.rs:90 |
| Type aliases | Cleaner Result signatures | src/database/errors.rs:110 |
.map_err() | Error transformation | Throughout codebase |
? operator | Error propagation | Throughout codebase |
References:
Key Takeaways
- Never use
anyhow::anyhow!()in production - Use structured error types - thiserror is the standard - Derive macro for custom errors
- Error hierarchies match domains - DatabaseError, ProviderError, AppError
#[from]enables?operator - Automatic error conversion- Add context to errors - Struct variants with meaningful fields
- HTTP mapping at boundaries - ErrorCode → status codes
- Retry logic in error types - ProviderError includes retry information
Next Chapter
Chapter 3: Configuration Management & Environment Variables - Learn how Pierre uses type-safe configuration with dotenvy, clap, and the algorithm selection system.