untyped-error
Level: warn · Article: Errors
With exceptions, you catch
Errorand guess. With typed errors, you match the variant and act. The difference is whether your recovery logic is a strategy or a prayer.
What it checks
A function returns Box<dyn Error>, anyhow::Result/anyhow::Error,
eyre, or Result<T, String> / Result<T, &str>. fn main and trait
impls are skipped: main may bubble anything, and Error::source is std’s
signature, not yours.
Don’t
#![allow(unused)]
fn main() {
fn fetch(url: &Url) -> Result<Response, Box<dyn Error>> {
Ok(http::get(url)?)
}
fn parse(input: &str) -> Result<Config, String> {
toml::from_str(input).map_err(|error| error.to_string())
}
}
The caller can display the error. It cannot retry on a timeout, refresh on an expired token, and give up on a validation failure, because it cannot tell them apart.
Do
#![allow(unused)]
fn main() {
enum FetchError {
Auth { refresh_token: RefreshToken },
Network { retry_after: Duration },
RateLimited { retry_after: Duration },
Validation(ValidationError),
}
fn fetch(url: &Url) -> Result<Response, FetchError> {
http::get(url).map_err(FetchError::from)
}
impl Client {
fn fetch_with_retry(&self, url: &Url) -> Result<Response, FetchError> {
match fetch(url) {
Ok(response) => Ok(response),
Err(FetchError::Network { retry_after } | FetchError::RateLimited { retry_after }) => {
self.clock.sleep(retry_after);
fetch(url)
}
Err(FetchError::Auth { refresh_token }) => {
self.refresh(refresh_token)?;
fetch(url)
}
Err(error @ FetchError::Validation(_)) => Err(error),
}
}
}
}
Granular enough to make different decisions. If two failures get identical handling, they are one variant.
Silence it
#![allow(unused)]
fn main() {
// rabot: allow(untyped-error) CLI entry point: every failure ends in the same exit code and message
fn run(args: Args) -> Result<(), Box<dyn Error>> { .. }
}