Rust Error Handling: Practical Patterns for Reliable Systems
Error handling in Rust transforms what’s often an afterthought into a core design element. I’ve found that treating errors as data unlocks maintainability that’s hard to achieve elsewhere. Let me share techniques that have made my Rust applications more resilient.
Structured error types create self-documenting failure paths. When building a network service last year, I defined explicit variants for every known failure mode:
#[derive(Debug, thiserror::Error)]
enum ApiError {
#[error("Database connection failed: {0}")]
Db(#[from] diesel::result::Error),
#[error("Authentication expired")]
AuthExpired,
#[error("Invalid payload: {0}")]
InvalidPayload(String),
}
fn save_user(user: User) -> Result<(), ApiError> {
let conn = establish_connection()?; // Auto-converts diesel errors
diesel::insert_into(users::table).execute(&conn)?;
Ok(())
}
The compiler ensures I handle each case. During debugging, the error messages pinpointed failures without stack traces.
Automatic error conversion bridges libraries seamlessly. In my configuration loader, I wrapped third-party errors:
impl From<serde_yaml::Error> for ConfigError {
fn from(err: serde_yaml::Error) -> Self {
ConfigError::Parse(format!("YAML error: {err}"))
}
}
fn load_config() -> Result<Config, ConfigError> {
let raw = std::fs::read("config.yaml")?;
let parsed: Config = serde_yaml::from_slice(&raw)?; // Auto-converted
Ok(parsed)
}
This pattern saved me from writing repetitive map_err
calls while preserving error context.
Context wrapping adds critical diagnostic layers. When debugging file processing issues, I attached operational context:
use anyhow::{Context, Result};
fn process_files(dir: &Path) -> Result<()> {
for entry in dir.read_dir().context("Failed reading directory")? {
let path = entry?.path();
let data = std::fs::read(&path)
.with_context(|| format!("Can't read {}", path.display()))?;
// Processing logic
}
Ok(())
}
The context chains appeared in logs: “Can’t read /data/file.txt: Permission denied”. This saved hours in incident investigations.
Domain-specific result types clarify function contracts. In my web framework project:
type HandlerResult = Result<HttpResponse, HandlerError>;
async fn user_handler(req: HttpRequest) -> HandlerResult {
let user_id = parse_id(&req)?; // Returns HandlerError on failure
let user = fetch_user(user_id).await?;
Ok(json_response(user))
}
This signature immediately communicates possible outcomes to other developers.
Iterator error handling maintains pipeline safety. Processing sensor data streams required:
fn parse_sensors(data: &[u8]) -> impl Iterator<Item = Result<Reading, SensorError>> + '_ {
data.chunks(SENSOR_SIZE)
.enumerate()
.map(|(i, chunk)| {
Reading::parse(chunk)
.map_err(|e| SensorError::new(i, e))
})
}
// Usage:
for reading in parse_sensors(&telemetry) {
match reading {
Ok(r) => process_reading(r),
Err(e) => log_error(e), // Continue processing valid items
}
}
Invalid chunks didn’t crash the entire processing pipeline.
Custom error traits unify handling. For my distributed system:
trait ServiceError: std::error::Error + Send + Sync {
fn severity(&self) -> ErrorSeverity {
ErrorSeverity::Normal
}
}
impl ServiceError for DatabaseError {
fn severity(&self) -> ErrorSeverity {
match self.code {
500 => ErrorSeverity::Critical,
_ => ErrorSeverity::Normal,
}
}
}
fn handle_error(e: &dyn ServiceError) {
metrics::increment!("errors", "severity" => e.severity().as_str());
if e.severity() == ErrorSeverity::Critical {
alert_engineers(e);
}
}
This allowed consistent monitoring across error types without pattern matching everywhere.
Recovery strategies enable graceful degradation. In a payment service:
fn process_payment(user_id: u64) -> Result<Receipt> {
primary_gateway(user_id)
.or_else(|_| {
warn!("Primary gateway failed, trying backup");
secondary_gateway(user_id)
})
.or_else(|_| {
error!("All payment methods failed");
queue_payment_retry(user_id)
})
}
We maintained partial functionality during third-party outages.
Batch validation collects multiple errors:
fn validate_form(form: &UserForm) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if form.username.is_empty() {
errors.push(ValidationError::new("username", "Cannot be empty"));
}
if !is_valid_email(&form.email) {
errors.push(ValidationError::new("email", "Invalid format"));
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
match validate_form(&submission) {
Ok(_) => save_user(),
Err(errors) => show_errors(errors), // Display all issues at once
}
Users corrected multiple problems in one submission cycle instead of facing sequential rejections.
These patterns fundamentally changed how I approach failure management. Explicit error handling initially felt verbose, but the dividends in debuggability and stability proved invaluable. The type system acts as a co-pilot, ensuring I consider failure paths during development rather than in production post-mortems. What I appreciate most is how these techniques compose - they can be mixed and matched to create robust error management strategies tailored to each application’s needs.