Rust Error Handling
Core principle: Start simple. Add structure when pain emerges.
See also: error_set patterns for detailed error_set! syntax and examples.
Don't Over-Engineer
rust
// ✅ Start here: just use the error directly
fn read_config(path: &Path) -> Result<Config, std::io::Error> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| std::io::Error::other(e))
}
// ✅ Still fine: anyhow/eyre for applications
fn load_app() -> eyre::Result<App> {
let config = read_config(Path::new("config.toml"))?;
let db = connect_db(&config.db_url)?;
Ok(App { config, db })
}
Only create custom error types when you feel the friction.
When to Create Custom Error Type
Signal: Repeated map_err with same types across multiple functions.
rust
// ❌ Pain: map_err everywhere
fn fetch_user() -> Result<User, ApiError> {
let data = db.query().map_err(|e| ApiError::Internal(e.to_string()))?;
let parsed = serde_json::from_str(&data).map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(parsed)
}
// ✅ Relief: error_set when pattern repeats 3+ times
error_set! {
RepoError := {
Db(sqlx::Error),
Parse(serde_json::Error),
};
}
fn fetch_user() -> Result<User, RepoError> {
let data = db.query()?;
let parsed = serde_json::from_str(&data)?;
Ok(parsed)
}
When unwrap/expect is Appropriate
Fail-fast at startup. Propagate at runtime.
| Phase | Approach |
|---|---|
main() initialization | expect("reason") |
| Config/pool setup | expect("reason") |
| Request handling | Result + ? |
| Library code | Result + ? |
rust
fn main() {
let config = Config::from_env().expect("Failed to load config");
let pool = Pool::connect(&config.database_url)
.await
.expect("Database connection required");
run_server(config, pool).await.unwrap();
}
Static vs Runtime Context
Inner data only for runtime information.
rust
error_set! {
ConfigError := {
// ✅ Static message - no inner data
#[display("Config file not found")]
NotFound,
// ❌ Redundant: message is always the same
#[display("Invalid config: {0}")]
InvalidConfig(String), // if always "missing required field"
// ✅ Runtime info - varies per call
#[display("Missing field: {0}")]
MissingField(String), // "host", "port", "timeout"
// ✅ Wrapped error
#[display("IO error: {0}")]
Io(std::io::Error),
};
}
Data Firewall (Public APIs Only)
Don't leak third-party errors from public crate APIs.
rust
// ❌ Leaks sqlx in public API pub fn get_user(id: &str) -> Result<User, sqlx::Error> // ✅ Wrapped for public API pub fn get_user(id: &str) -> Result<User, DbError>
Internal modules can use third-party errors directly.
Unexpected Errors
Use eyre::Report for truly unexpected errors with stack traces.
rust
error_set! {
DomainError := {
Unexpected(eyre::Report),
};
}
something().map_err(|e| DomainError::Unexpected(eyre::eyre!(e).wrap_err("context")))?;
Logging
| Error Type | Log Level |
|---|---|
| Business (InvalidEmail) | INFO/WARN |
| System (ConnectionFailed) | ERROR |
| Unexpected | ERROR + trace |
Log once at the edge, not at each layer.