Error Handling Patterns
Rust error handling patterns sử dụng Result<T, E> type cùng với các idioms và libraries như thiserror và anyhow để handle errors một cách idiomatic, type-safe và ergonomic.
Nguyên tắc
- Libraries: Dùng custom error types với
thiserror - Applications: Dùng
anyhow::Resultcho flexibility - Avoid
unwrap(): Chỉ dùng khi chắc chắn không fail - Contextual errors: Thêm context cho easier debugging
Basic Error Handling
Pattern 1: Result với Standard Errors
use std::fs::File; use std::io::{self, Read}; fn read_file(path: &str) -> io::Result<String> { let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) } fn main() { match read_file("data.txt") { Ok(contents) => println!("Contents: {}", contents), Err(e) => eprintln!("Error reading file: {}", e), } }
Pattern 2: Custom Error Enum
#[derive(Debug)] enum DataError { IoError(std::io::Error), ParseError(String), ValidationError(String), } impl std::fmt::Display for DataError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { DataError::IoError(e) => write!(f, "IO error: {}", e), DataError::ParseError(msg) => write!(f, "Parse error: {}", msg), DataError::ValidationError(msg) => write!(f, "Validation error: {}", msg), } } } impl std::error::Error for DataError {} impl From<std::io::Error> for DataError { fn from(error: std::io::Error) -> Self { DataError::IoError(error) } } fn process_data(data: &str) -> Result<i32, DataError> { if data.is_empty() { return Err(DataError::ValidationError("Data is empty".to_string())); } data.parse::<i32>() .map_err(|_| DataError::ParseError(format!("Cannot parse: {}", data))) } fn main() { match process_data("42") { Ok(num) => println!("Parsed: {}", num), Err(e) => eprintln!("Error: {}", e), } }
Using thiserror - For Libraries
thiserror giúp define custom error types dễ dàng hơn:
[dependencies]
thiserror = "1.0"
use thiserror::Error; #[derive(Error, Debug)] enum DataError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Parse error: {0}")] Parse(String), #[error("Validation failed: {field} is {reason}")] Validation { field: String, reason: String, }, #[error("Record not found: {0}")] NotFound(u64), #[error("Database error")] Database(#[from] DatabaseError), } #[derive(Error, Debug)] #[error("Database connection failed")] struct DatabaseError; // Automatic From implementation nhờ #[from] fn read_config() -> Result<String, DataError> { std::fs::read_to_string("config.toml")? // Auto convert io::Error } fn validate_email(email: &str) -> Result<(), DataError> { if !email.contains('@') { return Err(DataError::Validation { field: "email".to_string(), reason: "missing @ symbol".to_string(), }); } Ok(()) } fn main() { match validate_email("invalid") { Ok(_) => println!("Valid email"), Err(e) => eprintln!("Error: {}", e), // Output: Error: Validation failed: email is missing @ symbol } }
Using anyhow - For Applications
anyhow cung cấp error type linh hoạt cho applications:
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result, bail, ensure}; fn read_user_from_file(path: &str) -> Result<User> { let contents = std::fs::read_to_string(path) .context(format!("Failed to read user file: {}", path))?; let user: User = serde_json::from_str(&contents) .context("Failed to parse user JSON")?; ensure!(user.age >= 18, "User must be 18 or older, got {}", user.age); if user.name.is_empty() { bail!("User name cannot be empty"); } Ok(user) } #[derive(serde::Deserialize)] struct User { name: String, age: u32, } fn main() -> Result<()> { let user = read_user_from_file("user.json")?; println!("User: {}", user.name); Ok(()) } // Error chain example: // Error: Failed to read user file: user.json // // Caused by: // No such file or directory (os error 2)
Pattern: Error Context
Với anyhow
#![allow(unused)] fn main() { use anyhow::{Context, Result}; fn load_dataset(id: u64) -> Result<Vec<Record>> { let path = format!("data_{}.csv", id); let file = std::fs::File::open(&path) .with_context(|| format!("Failed to open dataset {}", id))?; let mut reader = csv::Reader::from_reader(file); let records: Vec<Record> = reader.deserialize() .collect::<Result<Vec<_>, _>>() .with_context(|| format!("Failed to parse CSV for dataset {}", id))?; ensure!(!records.is_empty(), "Dataset {} is empty", id); Ok(records) } #[derive(serde::Deserialize)] struct Record { id: u64, value: String, } }
Custom Context Chain
#![allow(unused)] fn main() { use anyhow::{Result, Context}; fn process_pipeline(input_path: &str, output_path: &str) -> Result<()> { extract(input_path) .context("Extract stage failed")?; transform() .context("Transform stage failed")?; load(output_path) .context("Load stage failed")?; Ok(()) } fn extract(path: &str) -> Result<()> { std::fs::read_to_string(path) .with_context(|| format!("Cannot read input file: {}", path))?; Ok(()) } fn transform() -> Result<()> { // Transform logic Ok(()) } fn load(path: &str) -> Result<()> { std::fs::write(path, "data") .with_context(|| format!("Cannot write output file: {}", path))?; Ok(()) } }
Pattern: Error Conversion
#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] enum PipelineError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("CSV error: {0}")] Csv(#[from] csv::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("Parse error: {0}")] Parse(#[from] std::num::ParseIntError), } fn read_csv(path: &str) -> Result<Vec<Vec<String>>, PipelineError> { let file = std::fs::File::open(path)?; // Auto convert io::Error let mut reader = csv::Reader::from_reader(file); let mut records = Vec::new(); for result in reader.records() { let record = result?; // Auto convert csv::Error records.push(record.iter().map(|s| s.to_string()).collect()); } Ok(records) } fn parse_numbers(data: &str) -> Result<Vec<i32>, PipelineError> { data.split(',') .map(|s| s.trim().parse::<i32>()) .collect::<Result<Vec<_>, _>>() .map_err(|e| e.into()) // Auto convert ParseIntError } }
Pattern: Result Extensions
trait ResultExt<T> { fn log_error(self) -> Self; fn with_field(self, field: &str, value: &str) -> Self; } impl<T, E: std::fmt::Display> ResultExt<T> for Result<T, E> { fn log_error(self) -> Self { if let Err(ref e) = self { eprintln!("Error occurred: {}", e); } self } fn with_field(self, field: &str, value: &str) -> Self { if let Err(ref e) = self { eprintln!("Error in {} ({}): {}", field, value, e); } self } } fn main() -> Result<(), Box<dyn std::error::Error>> { let result = process_user("user123") .log_error() .with_field("user_id", "user123")?; Ok(()) } fn process_user(id: &str) -> Result<(), Box<dyn std::error::Error>> { // Processing logic Ok(()) }
Pattern: Early Return với ?
use anyhow::Result; fn validate_and_process(data: &str) -> Result<i32> { // Early returns với ? let trimmed = data.trim(); ensure!(!trimmed.is_empty(), "Input is empty"); let number: i32 = trimmed.parse() .context("Failed to parse as integer")?; ensure!(number > 0, "Number must be positive, got {}", number); ensure!(number < 1000, "Number too large: {}", number); Ok(number * 2) } fn main() -> Result<()> { let result = validate_and_process(" 42 ")?; println!("Result: {}", result); Ok(()) }
Pattern: Multiple Error Types
#![allow(unused)] fn main() { use anyhow::Result; fn complex_operation() -> Result<String> { // Có thể mix nhiều error types khác nhau let file_contents = std::fs::read_to_string("config.toml")?; // io::Error let config: Config = toml::from_str(&file_contents)?; // toml::Error let response = reqwest::blocking::get(&config.url)?; // reqwest::Error let data = response.text()?; // reqwest::Error Ok(data) } #[derive(serde::Deserialize)] struct Config { url: String, } }
Pattern: Fallback với or_else
#![allow(unused)] fn main() { use anyhow::Result; fn read_config() -> Result<String> { // Try primary source std::fs::read_to_string("config.toml") .or_else(|_| { // Fallback to backup println!("Primary config not found, using backup"); std::fs::read_to_string("config.backup.toml") }) .or_else(|_| { // Last resort: default config println!("No config files found, using defaults"); Ok(String::from("[default]")) }) } }
Pattern: Collect Results
use anyhow::Result; fn process_files(paths: &[&str]) -> Result<Vec<String>> { // Collect results - dừng tại error đầu tiên paths.iter() .map(|path| std::fs::read_to_string(path)) .collect::<Result<Vec<_>, _>>() .map_err(|e| e.into()) } fn process_files_continue(paths: &[&str]) -> Vec<Result<String>> { // Process tất cả, collect các results paths.iter() .map(|path| { std::fs::read_to_string(path) .map_err(|e| e.into()) }) .collect() } fn main() -> Result<()> { let paths = vec!["file1.txt", "file2.txt", "file3.txt"]; // Stop on first error let results = process_files(&paths)?; // Or continue processing let all_results = process_files_continue(&paths); for (i, result) in all_results.iter().enumerate() { match result { Ok(contents) => println!("File {}: {} bytes", i, contents.len()), Err(e) => eprintln!("File {}: Error - {}", i, e), } } Ok(()) }
Real-World Example: Data Pipeline
use anyhow::{Context, Result, bail}; use thiserror::Error; #[derive(Error, Debug)] enum PipelineError { #[error("Validation failed: {0}")] Validation(String), #[error("Processing error at row {row}: {message}")] Processing { row: usize, message: String }, #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Csv(#[from] csv::Error), } struct DataPipeline { input_path: String, output_path: String, } impl DataPipeline { fn new(input: &str, output: &str) -> Self { DataPipeline { input_path: input.to_string(), output_path: output.to_string(), } } fn run(&self) -> Result<PipelineStats> { println!("Starting pipeline..."); let records = self.extract() .context("Extract stage failed")?; let processed = self.transform(records) .context("Transform stage failed")?; self.load(&processed) .context("Load stage failed")?; Ok(PipelineStats { records_processed: processed.len(), records_written: processed.len(), }) } fn extract(&self) -> Result<Vec<Record>, PipelineError> { let file = std::fs::File::open(&self.input_path)?; let mut reader = csv::Reader::from_reader(file); let mut records = Vec::new(); for result in reader.deserialize() { let record: Record = result?; records.push(record); } if records.is_empty() { return Err(PipelineError::Validation( "No records found in input file".to_string() )); } Ok(records) } fn transform(&self, records: Vec<Record>) -> Result<Vec<Record>, PipelineError> { let mut processed = Vec::new(); for (i, mut record) in records.into_iter().enumerate() { // Validate if record.value < 0.0 { return Err(PipelineError::Processing { row: i, message: format!("Negative value: {}", record.value), }); } // Transform record.value *= 1.1; processed.push(record); } Ok(processed) } fn load(&self, records: &[Record]) -> Result<(), PipelineError> { let file = std::fs::File::create(&self.output_path)?; let mut writer = csv::Writer::from_writer(file); for record in records { writer.serialize(record)?; } writer.flush()?; Ok(()) } } #[derive(Debug, serde::Serialize, serde::Deserialize)] struct Record { id: u64, name: String, value: f64, } struct PipelineStats { records_processed: usize, records_written: usize, } fn main() -> Result<()> { let pipeline = DataPipeline::new("input.csv", "output.csv"); match pipeline.run() { Ok(stats) => { println!("Pipeline completed successfully!"); println!(" Processed: {}", stats.records_processed); println!(" Written: {}", stats.records_written); } Err(e) => { eprintln!("Pipeline failed: {}", e); eprintln!("\nError chain:"); for cause in e.chain() { eprintln!(" - {}", cause); } return Err(e); } } Ok(()) }
Best Practices
1. Use ? for propagation
#![allow(unused)] fn main() { // ✅ Clean và concise fn read_data(path: &str) -> Result<String> { let data = std::fs::read_to_string(path)?; Ok(data) } // ❌ Verbose fn read_data_verbose(path: &str) -> Result<String> { match std::fs::read_to_string(path) { Ok(data) => Ok(data), Err(e) => Err(e.into()), } } }
2. Add context cho errors
#![allow(unused)] fn main() { // ✅ Helpful error messages use anyhow::Context; fn load_user(id: u64) -> Result<User> { let path = format!("users/{}.json", id); let data = std::fs::read_to_string(&path) .with_context(|| format!("Failed to load user {}", id))?; serde_json::from_str(&data) .with_context(|| format!("Failed to parse user {}", id)) } }
3. Avoid unwrap() trong production code
#![allow(unused)] fn main() { // ❌ Dangerous let value = some_operation().unwrap(); // ✅ Handle errors properly let value = some_operation() .context("Operation failed")?; // ✅ Or provide default let value = some_operation().unwrap_or_default(); }
4. Use thiserror cho libraries
#![allow(unused)] fn main() { // Library code - concrete error types #[derive(Error, Debug)] pub enum MyLibError { #[error("Configuration error: {0}")] Config(String), #[error("Network error: {0}")] Network(#[from] std::io::Error), } pub fn library_function() -> Result<(), MyLibError> { // ... Ok(()) } }
5. Use anyhow cho applications
// Application code - flexible errors use anyhow::Result; fn main() -> Result<()> { let config = load_config()?; let data = fetch_data(&config)?; process(data)?; Ok(()) }
When to Use What?
| Use Case | Recommendation |
|---|---|
| Library code | thiserror - Concrete error types |
| Application code | anyhow - Flexible error handling |
| Need error details | thiserror - Callers handle variants |
| Just propagate errors | anyhow - Simple error passing |
| Error context | Both - Use .context() |
| Multiple error types | anyhow - Easy mixing |
Tổng kết
Error handling patterns trong Rust:
- ✅ Type-safe với
Result<T, E> - ✅
thiserrorcho custom errors - ✅
anyhowcho application errors - ✅ Context cho better debugging
- ✅
?operator cho propagation
Best practices:
- Use
?thay vìunwrap() - Add context với
.context() thiserrorcho libraries,anyhowcho apps- Handle errors, không ignore
- Provide helpful error messages