Error Handling Patterns
Knowing the mechanics of Result and ? is step one. Knowing when to recover, when to propagate, when to panic, and how to produce useful error messages is what separates production-grade Rust from tutorial Rust. Error handling is a design decision, not just syntax.
Recoverable vs Unrecoverable Errors
The fundamental split in Rust error handling:
- Recoverable: The operation failed but the program can continue. A file was not found, a network request timed out, a user provided invalid input. Use
Result<T, E>. - Unrecoverable: The program is in an invalid state and cannot continue safely. An invariant was violated, an index was out of bounds on a vector the code controls, a programmer made an error. Use
panic!.
use std::fs;
fn load_required_config() -> String {
// If the config shipped with the binary is missing,
// the installation is broken. Panic is appropriate.
fs::read_to_string("/opt/myapp/default.conf")
.expect("default config must exist in installation directory")
}
fn load_user_config(path: &str) -> Result<String, std::io::Error> {
// User-specified config might not exist. That is recoverable.
fs::read_to_string(path)
}
fn main() {
let defaults = load_required_config();
match load_user_config("~/.myapp/config.toml") {
Ok(overrides) => println!("Loaded user config ({} bytes)", overrides.len()),
Err(_) => println!("No user config, using defaults"),
}
println!("Default config: {} bytes", defaults.len());
}
panic! Is for Bugs, Not Errors
panic! terminates the current thread (or the program, depending on configuration). Reserve it for situations that indicate a bug in your code:
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
// Bad: this is user input. Return an error.
// panic!("division by zero");
// Good: caller decides what to do
return f64::NAN;
}
a / b
}
struct Matrix {
data: Vec<Vec<f64>>,
}
impl Matrix {
fn get(&self, row: usize, col: usize) -> f64 {
// This panic is acceptable: if the caller passes an
// out-of-bounds index, it is a bug in the calling code.
assert!(row < self.data.len(), "row {} out of bounds", row);
assert!(col < self.data[row].len(), "col {} out of bounds", col);
self.data[row][col]
}
}
fn main() {
println!("{}", divide(10.0, 3.0));
println!("{}", divide(10.0, 0.0));
let m = Matrix {
data: vec![vec![1.0, 2.0], vec![3.0, 4.0]],
};
println!("{}", m.get(0, 1));
}
3.3333333333333335
NaN
2
Use assert!, assert_eq!, and debug_assert! for invariant checks. debug_assert! is removed in release builds — use it for expensive checks that catch bugs during development.
Error Context: Make Errors Useful
Bad error messages waste hours. Good error messages tell you what happened, where, and what to do about it.
use std::fs;
use std::path::Path;
#[derive(Debug)]
struct AppError {
message: String,
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
// Bad: "failed to read config"
// Why is this bad? Which config? What error?
// Good: "failed to read /etc/app/config.toml: permission denied"
// Now you know the file, the problem, and can fix it.
fn load_database_url(config_path: &Path) -> Result<String, AppError> {
let contents = fs::read_to_string(config_path).map_err(|e| AppError {
message: format!(
"failed to read config from {}: {}",
config_path.display(),
e
),
})?;
let url = contents
.lines()
.find(|l| l.starts_with("database_url="))
.map(|l| l.trim_start_matches("database_url=").to_string())
.ok_or_else(|| AppError {
message: format!(
"config at {} is missing required field 'database_url'",
config_path.display()
),
})?;
if !url.starts_with("postgres://") && !url.starts_with("mysql://") {
return Err(AppError {
message: format!(
"invalid database_url in {}: expected postgres:// or mysql:// scheme, got '{}'",
config_path.display(),
&url[..url.find(':').unwrap_or(url.len())]
),
});
}
Ok(url)
}
fn main() {
let path = Path::new("/etc/myapp/config.toml");
match load_database_url(path) {
Ok(url) => println!("Database: {}", url),
Err(e) => eprintln!("Error: {}", e),
}
}
Error: failed to read config from /etc/myapp/config.toml: No such file or directory (os error 2)
Logging Errors vs Returning Them
Not every error should bubble up. Sometimes the right response is to log it, apply a default, and continue:
use std::collections::HashMap;
fn load_feature_flags(path: &str) -> HashMap<String, bool> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let mut flags = HashMap::new();
for line in contents.lines() {
if let Some((key, value)) = line.split_once('=') {
if let Ok(enabled) = value.trim().parse::<bool>() {
flags.insert(key.trim().to_string(), enabled);
} else {
eprintln!("warning: invalid flag value for '{}': '{}'", key.trim(), value.trim());
}
}
}
flags
}
Err(e) => {
eprintln!("warning: could not load feature flags from {}: {}", path, e);
eprintln!("warning: using default flags");
HashMap::new()
}
}
}
fn main() {
let flags = load_feature_flags("features.conf");
let dark_mode = flags.get("dark_mode").copied().unwrap_or(false);
println!("Dark mode: {}", dark_mode);
}
warning: could not load feature flags from features.conf: No such file or directory (os error 2)
warning: using default flags
Dark mode: false
The decision framework:
- Return the error when the caller has context to decide how to handle it
- Log and continue when the failure is non-critical and a sensible default exists
- Panic when the error indicates a bug that makes further execution unsafe
The Early Return Pattern
Structure functions so errors are handled early and the main logic is unindented:
use std::collections::HashMap;
struct Request {
headers: HashMap<String, String>,
body: String,
}
#[derive(Debug)]
struct Response {
status: u16,
body: String,
}
fn handle_request(req: &Request) -> Response {
// Validate early, return errors immediately
let auth = match req.headers.get("Authorization") {
Some(token) => token,
None => return Response {
status: 401,
body: "missing Authorization header".to_string(),
},
};
if !auth.starts_with("Bearer ") {
return Response {
status: 401,
body: "invalid Authorization format".to_string(),
};
}
let token = &auth[7..];
if token.is_empty() {
return Response {
status: 401,
body: "empty token".to_string(),
};
}
// Happy path — all validation passed
let data = process_with_token(token, &req.body);
Response {
status: 200,
body: data,
}
}
fn process_with_token(token: &str, body: &str) -> String {
format!("processed {} bytes for token {}", body.len(), &token[..8.min(token.len())])
}
fn main() {
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer abc12345xyz".to_string());
let req = Request {
headers,
body: "hello world".to_string(),
};
let resp = handle_request(&req);
println!("{}: {}", resp.status, resp.body);
}
200: processed 11 bytes for token abc12345
Retry Pattern
For transient failures, a simple retry with backoff:
use std::thread;
use std::time::Duration;
#[derive(Debug)]
enum FetchError {
Timeout,
ServerError(u16),
NotFound,
}
fn fetch_data(url: &str) -> Result<String, FetchError> {
// Simulating an unreliable service
static mut CALLS: u32 = 0;
unsafe {
CALLS += 1;
if CALLS < 3 {
return Err(FetchError::Timeout);
}
}
Ok(format!("data from {}", url))
}
fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, FetchError> {
let mut last_error = FetchError::Timeout;
for attempt in 0..=max_retries {
match fetch_data(url) {
Ok(data) => return Ok(data),
Err(FetchError::NotFound) => return Err(FetchError::NotFound), // don't retry 404
Err(e) => {
eprintln!("attempt {}: {:?}, retrying...", attempt + 1, e);
last_error = e;
if attempt < max_retries {
thread::sleep(Duration::from_millis(100 * 2u64.pow(attempt)));
}
}
}
}
Err(last_error)
}
fn main() {
match fetch_with_retry("https://api.example.com/data", 3) {
Ok(data) => println!("Success: {}", data),
Err(e) => eprintln!("Failed after retries: {:?}", e),
}
}
attempt 1: Timeout, retrying...
attempt 2: Timeout, retrying...
Success: data from https://api.example.com/data
Notice that NotFound is not retried — retrying a 404 is pointless. The error type determines the retry strategy.
Converting Between Option & Result
fn find_port_in_config(config: &str) -> Result<u16, String> {
config
.lines()
.find(|l| l.starts_with("port="))
.ok_or_else(|| "missing 'port' in config".to_string())? // Option -> Result
.trim_start_matches("port=")
.parse::<u16>()
.map_err(|e| format!("invalid port: {}", e))
}
fn find_user(id: u64) -> Option<String> {
let result: Result<String, String> = lookup_database(id);
result.ok() // Result -> Option (discards the error)
}
fn lookup_database(id: u64) -> Result<String, String> {
if id == 1 {
Ok("Alice".to_string())
} else {
Err("not found".to_string())
}
}
fn main() {
println!("{:?}", find_port_in_config("host=localhost\nport=8080"));
println!("{:?}", find_port_in_config("host=localhost"));
println!("{:?}", find_user(1));
println!("{:?}", find_user(99));
}
Ok(8080)
Err("missing 'port' in config")
Some("Alice")
None
ok_or / ok_or_else converts Option to Result. .ok() converts Result to Option (discarding the error).
Common Pitfalls
- Catching panics with
std::panic::catch_unwind. This is for FFI boundaries and test harnesses, not error handling. If you are catching panics in business logic, redesign your error handling. - Logging the same error at multiple levels. If each layer logs the error and then propagates it, you get the same error printed 5 times. Log at the point where you handle the error, not where you propagate it.
- Treating all errors the same. A timeout is different from a permission error is different from invalid input. Your error handling should distinguish them when the response differs.
- Swallowing errors silently.
let _ = risky_operation();suppresses the result. If you intentionally ignore an error, add a comment explaining why. Otherwise, handle it. - Not testing error paths. Write tests that trigger every error variant. The happy path is easy. The edge cases are where bugs live.
- Excessive wrapping. Not every function needs its own error type. If you are just passing errors through, a single application-level error type (or
anyhow) is fine.
Key Takeaways
- Recoverable errors use
Result. Unrecoverable bugs usepanic!. The distinction is a design decision. - Error messages should include what failed, where, and enough context to diagnose the problem.
- Log errors at the handling site, not at every propagation step.
- Use early returns for validation to keep the happy path clean and unindented.
- Not all errors should be retried. Match on the error type to decide the strategy.
ok_or_elseconvertsOptiontoResult..ok()goes the other direction. Use them to bridge the two types.