10. Error handling: Result, ?, and panics
10.1 Result<T, E>
use std::num::ParseIntError;
fn parse_kv(line: &str) -> Result<(String, u32), String> {
let (k, v) = line.split_once('=').ok_or(format!("missing '=' in {line:?}"))?;
let v = v.trim();
let v: u32 = v.parse().map_err(|e: ParseIntError| format!("bad value {v:?}: {e}"))?;
Ok((k.trim().to_string(), v))
}
fn main() {
for line in ["workers = 8", "timeout", "retries = many"] {
match parse_kv(line) {
Ok((k, v)) => println!("{k} -> {v}"),
Err(e) => println!("error: {e}"),
}
}
}
The ? operator means "if this is Err(e), return Err(e.into()) from
the current function, otherwise unwrap the Ok value". It does the same job
as Go's if err != nil { return err }, in one character, and it calls
From::from to convert the error type on the way out.
| Go | C | Rust | |
|---|---|---|---|
| Signal failure | return nil, err |
return -1; errno = EINVAL |
return Err(e) |
| Propagate | if err != nil { return err } |
if (rc < 0) goto fail; |
? |
| Can you ignore it? | yes, v, _ := f() |
yes, very easily | #[must_use] warning; you must write let _ = explicitly |
| Wrap with context | fmt.Errorf("...: %w", err) |
— | .context("...") (anyhow), or a source field (thiserror) |
| Inspect | errors.Is / errors.As |
compare errno |
match on the enum, downcast_ref |
10.2 Custom error types with thiserror (libraries)
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot read config file {path}")]
Io { path: String, #[source] source: std::io::Error },
#[error("line {line}: {msg}")]
Syntax { line: usize, msg: String },
#[error("unknown key {0:?}")]
UnknownKey(String),
}
fn load(path: &str) -> Result<String, ConfigError> {
std::fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.into(), source })
}
fn main() {
match load("/definitely/missing.toml") {
Err(ConfigError::Io { path, source }) => println!("io error on {path}: {source}"),
Err(e) => println!("{e}"),
Ok(s) => println!("{} bytes", s.len()),
}
println!("{}", ConfigError::Syntax { line: 3, msg: "expected '='".into() });
println!("{}", ConfigError::UnknownKey("colour".into()));
}
10.3 anyhow for applications
use anyhow::{bail, Context, Result};
fn read_port(path: &str) -> Result<u16> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading port file {path}"))?;
let port: u16 = text.trim().parse().context("port file is not a number")?;
if port < 1024 { bail!("refusing privileged port {port}"); }
Ok(port)
}
fn main() {
if let Err(e) = read_port("/nonexistent/port") {
// {:#} prints the whole chain: "reading port file ...: No such file or directory (os error 2)"
println!("{e:#}");
}
}
The rule most Rust teams use: libraries define precise error enums with
thiserror, so callers can match on them. Binaries use anyhow::Result
and add .context(...) at each layer, so the final message reads like a
stack of reasons.
10.4 Panics are for bugs
panic!, unwrap(), expect(), out-of-bounds indexing, and integer
overflow in debug builds all panic. By default a panic unwinds the
thread's stack and runs Drop for every live value. If it reaches the top
of main, the process exits with code 101.
fn main() {
let config_from_build: Option<&str> = Some("prod");
// expect() documents WHY you believe this cannot fail. Prefer it to unwrap().
let env = config_from_build.expect("build script always sets the environment");
let r = std::panic::catch_unwind(|| {
let v: Vec<i32> = Vec::new();
v[0] // index out of bounds: panic
});
println!("{env}, caught panic: {}", r.is_err());
// The default panic hook still prints "index out of bounds: the len is 0 but
// the index is 0" to stderr. catch_unwind stops the unwinding, not the message.
}
catch_unwind is for boundaries, such as a thread pool keeping a worker
alive or an FFI function that must not unwind into C. It is not a
try/catch for normal error handling.
When to panic: a broken invariant (a bug), tests, prototypes, and startup
code that cannot continue. When to return Result: anything caused by input,
the environment, or the network. A panic in a request handler is a
denial-of-service bug (Ch 51). Axum and Tokio isolate panics per task,
but you should not rely on that.
10.5 panic = "abort" and FFI
With panic = "abort" in the profile, a panic kills the process instead of
unwinding. You get smaller binaries and no catch_unwind. A panic must
never unwind across an extern "C" boundary into C code. Since Rust 1.81
that aborts the process (Ch 34).