Handling errors without panicking
This step finishes: the work of the last two failure-related decisions. hit has made one
promise since it started returning Result:
nothing panics, failures print a chain and exit 1. What it has not had is its own words — every
error so far is reqwest’s error, printed in reqwest’s phrasing, and the best the program could do
was pass it along. Here hit gets an error type of its own, and the messages start telling the
user what to do about the problem.
You need the project as the previous article left it. If you are starting from scratch, that state
is tagged cli-ch04 in the examples
repository:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch04Add thiserror
Section titled “Add thiserror”cargo add thiserror[dependencies]clap = { version = "4", features = ["derive"] }reqwest = { version = "0.13", features = ["blocking"] }thiserror = "2"anyhow is gone. It was the right size for the last step:
one line of setup, and every failure became a printed chain. But look at what that chain printed:
Error: error sending request for url (https://this-domain-really-does-not-exist-9f3k.com/)That sentence belongs to reqwest. It is honest, and it is useless to someone who does not have
hit’s source open — it says a request failed, not what to try next. anyhow answers the
developer’s question (“what happened, and what caused it?”); it has no way to answer the user’s
question (“what do I do now?”). Answering that needs errors you have named yourself.
thiserror is the tool for that. It is a derive macro that writes the boilerplate of a custom
error type — the Display impl, the Error::source plumbing, the From conversions — so the
type itself stays visible.
What the derive gives you
Section titled “What the derive gives you”A small example first, outside hit:
use thiserror::Error;
#[derive(Debug, Error)]enum ConfigError { #[error("missing field `{0}` in the config file")] MissingField(String),}
fn main() { let error = ConfigError::MissingField("timeout".to_string()); println!("Display: {error}"); println!("Debug: {error:?}");}Display: missing field `timeout` in the config fileDebug: MissingField("timeout")The #[error("...")] attribute writes Display. {0} interpolates the first tuple field; a
struct variant’s fields interpolate by name. Debug is still the ordinary derived one, and that
distinction matters in a moment.
The error type hit needs
Section titled “The error type hit needs”Now the real one. hit has three distinct ways to fail that deserve their own names — the user
typed something wrong, the host is unreachable, the host is too slow — plus a bucket for
everything else:
use thiserror::Error;
#[derive(Debug, Error)]enum HitError { #[error("{url} did not answer within 10 seconds")] Timeout { url: String, #[source] source: reqwest::Error, },
#[error("could not reach {url}")] Connect { url: String, #[source] source: reqwest::Error, },
#[error("invalid --header {header:?}: expected \"Name: value\"")] InvalidHeader { header: String },
#[error(transparent)] Request(#[from] reqwest::Error),}Two attributes are doing work here.
#[source] marks the field that is the cause of this error — the error underneath it. When
hit prints its chain later, could not reach <url> is the headline and the reqwest error
sits underneath it, reachable through Error::source().
#[from] on Request generates a From<reqwest::Error> impl, which is what keeps ? working:
response.text()? and Client::builder().build()? both return plain reqwest::Error, and the
conversion happens automatically. #[error(transparent)] makes the Display of this variant
be the reqwest error’s own message. For some failures reqwest’s words are already the best
explanation — “too many redirects” needs no rewriting — and transparent means hit carries the
error without rewording it.
Printing the error yourself
Section titled “Printing the error yourself”The obvious shape — fn main() -> Result<(), HitError> — has a trap. When main returns Err,
Rust prints the error with its Debug formatting. For the demo type above, a real run prints:
Error: MissingField("timeout")For hit’s struct variants that is a field dump nobody can read. So main does not return the
error; it handles it. Here is the full src/main.rs:
use std::error::Error;use std::process::ExitCode;use std::time::Duration;
use clap::{Parser, Subcommand};use reqwest::blocking::{Client, RequestBuilder, Response};use thiserror::Error;
#[derive(Parser)]#[command(name = "hit", version, about = "A tiny command-line HTTP client")]struct Cli { /// Print what we are about to do #[arg(short, long, global = true)] verbose: bool,
#[command(subcommand)] command: Command,}
#[derive(Subcommand)]enum Command { /// Send a GET request Get { /// The URL to request url: String,
/// Send this header, for example -H "Accept: application/json" #[arg(short = 'H', long = "header")] headers: Vec<String>, },
/// Send a POST request with a body Post { /// The URL to request url: String,
/// Request body #[arg(short, long)] body: Option<String>, },}
#[derive(Debug, Error)]enum HitError { #[error("{url} did not answer within 10 seconds")] Timeout { url: String, #[source] source: reqwest::Error, },
#[error("could not reach {url}")] Connect { url: String, #[source] source: reqwest::Error, },
#[error("invalid --header {header:?}: expected \"Name: value\"")] InvalidHeader { header: String },
#[error(transparent)] Request(#[from] reqwest::Error),}
fn main() -> ExitCode { let cli = Cli::parse();
match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("hit: {e}"); let mut source = e.source(); while let Some(err) = source { eprintln!(" because: {err}"); source = err.source(); } ExitCode::FAILURE } }}
fn run(cli: Cli) -> Result<(), HitError> { let client = Client::builder() .timeout(Duration::from_secs(10)) .build()?;
match cli.command { Command::Get { url, headers } => { if cli.verbose { eprintln!("GET {url}"); for header in &headers { eprintln!(" header: {header}"); } }
let mut request = client.get(&url); for header in &headers { let (name, value) = header .split_once(':') .ok_or_else(|| HitError::InvalidHeader { header: header.clone() })?; request = request.header(name.trim(), value.trim()); }
let response = send(request, &url)?; print_response(response)?; }
Command::Post { url, body } => { if cli.verbose { eprintln!("POST {url}"); }
let mut request = client.post(&url); if let Some(body) = body { request = request.body(body); }
let response = send(request, &url)?; print_response(response)?; } }
Ok(())}
fn send(request: RequestBuilder, url: &str) -> Result<Response, HitError> { request.send().map_err(|error| match error { e if e.is_timeout() => HitError::Timeout { url: url.to_string(), source: e, }, e if e.is_connect() => HitError::Connect { url: url.to_string(), source: e, }, e => HitError::Request(e), })}
fn print_response(response: Response) -> Result<(), HitError> { println!("{}", response.status()); for (name, value) in response.headers() { println!("{name}: {}", value.to_str().unwrap_or("<binary>")); } println!(); let body = response.text()?; println!("{body}"); Ok(())}Three things changed from the last article, all in the error path.
main returns ExitCode, not a Result. It calls run(cli), which returns
Result<(), HitError> and holds everything the old main body did. On Ok, the process exits
with status 0. On Err, it prints hit: plus the error’s Display — the message you wrote —
then walks the chain with Error::source() and prints one because: line per level. Errors go
to stderr, so a script piping hit’s stdout is not polluted by them. ExitCode::FAILURE is
status 1, the same code anyhow was producing, so nothing downstream notices the change.
Calling e.source() needs the Error trait in scope — that is the use std::error::Error; at
the top, and it is easy to forget because the compiler’s suggestion names a trait import rather
than a missing method.
send() classifies the reqwest error. request.send() is the one call that can fail in three
distinct ways, so it maps the error through is_timeout() and is_connect() — reqwest’s own
predicates on reqwest::Error — and wraps it in the matching variant. Everything else falls
through to the transparent Request. The classification is one match with guards; when a later
step colours the output, matching on the error kind starts here.
with_context became ok_or_else. The --header check used to build a string through
anyhow’s with_context; now it constructs HitError::InvalidHeader directly. Same message —
that one was already written from the user’s side of the screen.
What failure looks like now
Section titled “What failure looks like now”Same four failures as the last article, new words.
A domain that does not exist:
cargo run -- get https://this-domain-really-does-not-exist-9f3k.comhit: could not reach https://this-domain-really-does-not-exist-9f3k.com because: error sending request for url (https://this-domain-really-does-not-exist-9f3k.com/) because: client error (Connect) because: Connection reset by peer (os error 54)The headline is hit’s. The chain is still there underneath — the last line depends on your
network, which is why it is printed rather than thrown away — but now the first line is written
for the person running the command. Exit code 1.
A host that accepts the connection and never answers:
cargo run -- get https://httpbin.org/delay/10hit: https://httpbin.org/delay/10 did not answer within 10 seconds because: error sending request for url (https://httpbin.org/delay/10) because: operation timed outA malformed -H argument, which never reaches the network at all:
cargo run -- get https://example.org -H "Accept"hit: invalid --header "Accept": expected "Name: value"And a redirect limit, handled by the transparent variant — reqwest’s message passes through unchanged, one layer down:
cargo run -- get https://httpbin.org/redirect/11hit: error following redirect for url (https://httpbin.org/redirect/11) because: too many redirectsUsage errors never reach any of this. clap handles a bad flag itself, prints its own message, and
exits 2 — the conventional exit code for “the command line was wrong”, distinct from hit’s 1 for
“the command was fine, the world was not”.
One fallback deliberately stays: value.to_str().unwrap_or("<binary>") in print_response.
Header values are bytes, and a response full of headers you cannot print is still a response that
succeeded. Failing the whole request over one weird header would be an error invented for the
programmer’s comfort. That is not a panic being left in — unwrap_or cannot panic — it is a
decision about which failures matter.
Where the code lives
Section titled “Where the code lives”This step’s end state is tagged
cli-ch05 in the
examples repository — the project exactly as this
article leaves it:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch05If your output differs from the article’s, checking out the tag and diffing it against your own tree shows which line drifted.
hit now succeeds politely and fails in its own words. Next it starts looking like a finished
tool: status codes coloured by class, response headers in a table. The match arms in send() and
print_response are exactly where that colouring hooks in.