Rust error design
Convert ad-hoc error handling to typed errors. The wrong shape leaks through public APIs and infects every caller, so this refactor is usually worth doing early.
Decision tree
Is this code a library / reusable module (has consumers who may need to
match on error variants, retry, or wrap)?
├── YES → thiserror. Define a concrete error enum per module.
│ Use #[from] for upstream errors so `?` composes cleanly.
│ Never return Box<dyn Error> or Result<T, String>.
│
└── NO → It's a binary / application top layer.
anyhow::Result<T> with .context("...") at each layer.
Use anyhow::bail!() / ensure!() for ad-hoc errors.
Match on root_cause() / downcast_ref() only at boundaries.
A single crate often needs both: thiserror enums in library modules, anyhow::Result in main.rs / CLI glue.
thiserror template
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VaultError {
#[error("failed to build vault client settings")]
Settings(#[source] vaultrs::client::VaultClientSettingsBuilderError),
#[error("failed to create vault client")]
Client(#[source] vaultrs::error::ClientError),
#[error("initialization returned no keys")]
NoKeys,
#[error("vault status request failed")]
Status(#[from] vaultrs::error::ClientError),
}
Rules:
- One enum per module / per logical surface. Don't make a megaenum spanning the whole crate.
- Each variant gets a
#[error("...")]message that reads as a sentence fragment (lowercase, no trailing period, no "Error:" prefix —Displayadds context). - Wrap source errors with
#[source](manual) or#[from](auto-converts via?). Use#[from]only when the variant has no other fields and conversion is unambiguous. - Don't include sensitive data (tokens, keys) in
#[error("...")]format strings.
anyhow template
use anyhow::{Context, Result};
async fn run() -> Result<()> {
let vault = vault::client()
.await
.context("starting vault client")?;
let k8s = k8s::client()
.await
.context("starting kubernetes client")?;
ensure(&vault, &k8s)
.await
.context("ensuring vault is ready")?;
Ok(())
}
Rules:
.context("...")at every boundary you cross. The chain becomes the error message — read top-down it tells the story.- Context strings are lowercase verb phrases ("starting vault client"), not sentences.
anyhow::bail!("...")to return an ad-hoc error;anyhow::ensure!(cond, "...")for precondition checks.
Refactor patterns
Pattern: stringly-typed → typed
Before:
.map_err(|e| format!("Failed to build Vault client settings: {:?}", e))?
After (library):
.map_err(VaultError::Settings)? // typed variant, source preserved
After (application):
.context("building vault client settings")? // anyhow chain
Pattern: Box<dyn Error> in public API → typed enum
If the function is pub in a lib.rs or pub mod, the boxed-error return is a Major issue. Callers can't match. Replace with a thiserror enum.
If the function is private and only main consumes it, anyhow is fine — but consider promoting to a typed error if the function is non-trivial.
Pattern: log-and-return → log-once-at-top
Before (every layer does this):
match foo().await {
Ok(v) => Ok(v),
Err(e) => {
error!("Failed to foo: {:?}", e); // <-- log
Err(e) // <-- and propagate
}
}
After:
foo().await.context("doing foo")? // attach context, propagate
Then log exactly once at the top of the call stack (main, request handler, task spawn):
if let Err(e) = run().await {
error!(error = ?e, "vault sidecar failed");
std::process::exit(1);
}
This produces ONE log line per failure with the full chain, instead of N duplicated logs.
Pattern: silent fallback → surfaced failure
Before:
pub async fn namespace() -> String {
match k8s::namespace().await {
Ok(ns) => ns,
Err(e) => {
error!("Failed: {:?}", e);
"default".to_string() // hide the error
}
}
}
After:
pub async fn namespace() -> Result<String, K8sError> {
k8s::namespace().await // let the caller decide
}
If a default genuinely is the right behavior, name it: unwrap_or_default_namespace() and document why.
Workflow
- Identify the boundary: which crate is this code?
lib(typed) vsbin(anyhow)? - Inventory current error shapes (
grep -nE 'Box<dyn|Result<.*String>|map_err\(\|.*format!'). - Sketch the enum(s) — one per module. Show to the user before mass-editing.
- Convert mechanically: variants →
#[from]where unambiguous,#[source]elsewhere. - Replace
.map_err(|e| format!(...))with the typed variant or.context(...). - Move all
error!logging to the top of the call stack. Lower layers only attach context. - Run
cargo check && cargo clippy— fix the cascade of breakage. - Update tests that asserted on
e.to_string().contains(...)to match on variants.
Anti-patterns to never reintroduce
Result<T, String>in any function signature.Box<dyn Error>in a public library function (returning it frommainis fine).anyhow::Errorin a library's public API (consumers can't match on it).- Megaenum
AppErrorwith 40 variants spanning the whole crate. #[from]on multiple variants that wrap the same upstream type —?becomes ambiguous; use#[source]+ explicit.map_err.- Including secrets (tokens, passwords) in
#[error("...")]strings.
Source: outsideorbit/vaulpner — distributed by TomeVault.