Skip to content
Select theme

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:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch04
Terminal window
cargo add thiserror
Cargo.toml
[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.

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 file
Debug: 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.

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.

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:

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.

Same four failures as the last article, new words.

A domain that does not exist:

Terminal window
cargo run -- get https://this-domain-really-does-not-exist-9f3k.com
hit: 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:

Terminal window
cargo run -- get https://httpbin.org/delay/10
hit: 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 out

A malformed -H argument, which never reaches the network at all:

Terminal window
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:

Terminal window
cargo run -- get https://httpbin.org/redirect/11
hit: error following redirect for url (https://httpbin.org/redirect/11)
because: too many redirects

Usage 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.

This step’s end state is tagged cli-ch05 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch05

If 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.

← Back to the track overview