Error Handling Patterns
Use this recipe for domain errors, library errors, service errors, and application boundary error handling.
Domain Error Type
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ServiceError>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ServiceError {
#[error("not found: {message}")]
NotFound { message: String },
#[error("invalid input: {message}")]
InvalidInput { message: String },
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("serialization error")]
Serialization(#[from] serde_json::Error),
}
impl ServiceError {
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound {
message: message.into(),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::InvalidInput {
message: message.into(),
}
}
}Use #[non_exhaustive] for public error enums so new variants can be added without a breaking change.
Application Boundary with anyhow
Use anyhow at the binary boundary, not inside reusable libraries:
use anyhow::Context;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = AppConfig::from_env().context("failed to load application configuration")?;
run(config).await.context("service exited with an error")?;
Ok(())
}Rules
- Prefer
?over manualmatchwhen adding no useful context. - Add context at system boundaries: file paths, endpoints, resource IDs, operation names.
- Do not log and return the same error unless the log adds operational context.
- Do not include secrets or tokens in error messages.
- Public functions that return
Resultshould document error cases. - Avoid
unwrapandexpectin production paths. Startup-onlyexpectis acceptable when the message is specific and practical.
Error Documentation
/// Loads application configuration.
///
/// # Errors
///
/// Returns an error when required environment variables are missing or malformed.
pub fn load_config() -> Result<AppConfig> {
AppConfig::from_env()
}Mapping External Errors
Prefer explicit domain mapping when external errors should not leak through the public API:
pub async fn find_user(id: UserId, client: &UserClient) -> Result<Option<User>> {
client
.find(id.as_str())
.await
.map_err(|error| ServiceError::InvalidInput {
message: format!("user lookup failed: {error}"),
})
}For libraries, avoid exposing unstable external error types unless they are already part of the intended public contract.