Making a real HTTP request with reqwest
In this article: hit stops pretending. Until now hit get https://example.org has parsed the
arguments and printed would GET ... — a real command line with nothing behind it. Here the request
actually goes out, and the output becomes the status line, the response headers, and the body.
You need the project as the previous article
left it. If you are starting from scratch, that state is tagged
cli-ch03 in the examples repository:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch03Add the dependencies
Section titled “Add the dependencies”cargo add reqwest --features blockingcargo add anyhow[dependencies]anyhow = "1"clap = { version = "4", features = ["derive"] }reqwest = { version = "0.13", features = ["blocking"] }The client reqwest gives you by default is async: every call returns a future, and
reqwest::blocking does not even exist until you ask for it. hit is a plain synchronous program
that makes one request and exits — that is what the blocking feature is for. The track switches
hit to async in its own step; until then, blocking is the honest choice. The json feature stays
off for now: nothing here parses JSON yet, and the
setup step made the case against declaring
things you do not use. Features count too.
anyhow is back. Step 2 removed it because nothing used it; now something does. It exists for the
layer you are writing — an application, not a library — where you do not want to design an error
type for every call that can fail. You want “something went wrong, here is the chain of why”, and
you want it in one line. The next article will outgrow that and give hit a real error type; for
this step, anyhow is the right size.
Send the request
Section titled “Send the request”Replace the contents of src/main.rs:
use std::time::Duration;
use anyhow::{Context, Result};use clap::{Parser, Subcommand};use reqwest::blocking::{Client, Response};
#[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>, },}
fn main() -> Result<()> { let cli = Cli::parse();
let client = Client::builder() .timeout(Duration::from_secs(10)) .build() .context("failed to build the HTTP client")?;
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(':').with_context(|| { format!("invalid --header {header:?}: expected \"Name: value\"") })?; request = request.header(name.trim(), value.trim()); }
let response = request.send()?; 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 = request.send()?; print_response(response)?; } }
Ok(())}
fn print_response(response: Response) -> Result<()> { 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(())}Try it
Section titled “Try it”cargo run -- get https://example.org200 OKdate: Fri, 16 Oct 2026 14:04:09 GMTcontent-type: text/html; charset=utf-8server: cloudflarelast-modified: Tue, 13 Oct 2026 22:05:48 GMTallow: GET, HEADaccept-ranges: bytesage: 5046cache-control: max-age=14400cf-cache-status: HITcf-ray: a4b79c6f09f29ac1-FRAalt-svc: h3=":443"; ma=86400
<!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=viewport content="width=device-width,initial-scale=1"><title>Example Domain</title><style>html{color-scheme:light dark;background:light-dark(#eee,#222)}body{font:16px/1.6 system-ui,sans-serif;max-width:26em;margin:auto;padding:25vh 2em 2em;text-align:center}</style></head><body><p>This domain is for use in documentation examples without needing permission. This is not a service; avoid relying on it for testing and monitoring purposes.</p><script src=/s.js></script></body></html>Your header values will not match these, and they should not: date, age, and cf-ray are
different on every response. The shape is what matters — status on the first line, headers, a blank
line, then the body.
Reading the response
Section titled “Reading the response”print_response does three things, and each one has a type behind it worth knowing.
response.status() returns a StatusCode, and its Display includes the reason phrase — that is
why the first line reads 200 OK rather than 200. When hit learns to colour its output,
matching on this value is where that starts: status.is_success(), status.as_u16(), both on the
type already in hand.
The headers loop iterates response.headers(), which yields (&HeaderName, &HeaderValue) pairs.
Header values are bytes, not strings — a server can send a header full of bytes that are not valid
text — so value.to_str() returns a Result, and unwrap_or("<binary>") prints <binary> instead
of crashing on the one weird header. When a step replaces unwrap_or with real error
handling, this loop is where that starts.
response.text() consumes the response and blocks until the whole body has arrived, then hands it
back as a String. That is the blocking feature doing its job: the call does not return until the
download is done. In the async client this same method is async and returns a future; in the
blocking client it is an ordinary function call.
What failure looks like now
Section titled “What failure looks like now”main returns anyhow::Result<()>, and every fallible call behind it uses ?. When one fails, the
error propagates up, main returns Err, and Rust prints the error and exits with status 1. Send
hit after a domain that does not exist:
cargo run -- get https://this-domain-really-does-not-exist-9f3k.comError: error sending request for url (https://this-domain-really-does-not-exist-9f3k.com/)
Caused by: 0: client error (Connect) 1: Connection reset by peer (os error 54)Exit code 1, no panic, no backtrace. The Caused by: chain is anyhow printing each error and the
error that caused it, top to bottom. The last line depends on your network — a dead domain surfaces
as a connection reset on this one, and as a DNS error on many others — which is exactly why the
chain is printed instead of one hard-coded sentence.
Had main returned nothing and the code used .unwrap() instead of ?, the same failure would
kill the process with a panic message and none of this context. That is the trade the last article
promised to revisit.
with_context adds a link to that chain, and it is how --header arguments get a readable
failure. Splitting -H "Accept" on : finds no colon, so split_once returns None, and the
context turns that into:
cargo run -- get https://example.org -H "Accept"Error: invalid --header "Accept": expected "Name: value"Without the context, the reader would get “invalid header” at best and a bare None unwrap panic
at worst. One with_context line, and the error tells the user what to type instead.
A timeout that works
Section titled “A timeout that works”This is the line doing the quiet work:
let client = Client::builder() .timeout(Duration::from_secs(10)) .build()Client::new() would also build a client — with no timeout at all. Then a server that accepts the
connection and never answers hangs hit forever, and the user gets to learn that with Ctrl-C. The
builder’s timeout covers the whole request: from the start of the connection until the last byte
of the body has arrived. Ten seconds is a guess, but a bounded one.
To see it fire you need a server that takes longer than ten seconds to answer, and
httpbin.org/delay/10 is exactly that — it sleeps ten seconds server-side before replying, which
is already too late:
cargo run -- get https://httpbin.org/delay/10Error: error sending request for url (https://httpbin.org/delay/10)
Caused by: operation timed outThe wall clock read about 10.5 seconds. The exit code is 1, the chain says why, and the program ended instead of hanging.
Sending headers and a body
Section titled “Sending headers and a body”The -H loop from the last article now does something. Run it verbosely to see both sides:
cargo run -- get https://example.org -H "Accept: text/plain" -vGET https://example.org header: Accept: text/plain200 OKdate: Fri, 16 Oct 2026 14:04:31 GMTcontent-type: text/html; charset=utf-8server: cloudflarelast-modified: Tue, 13 Oct 2026 22:05:48 GMTallow: GET, HEADaccept-ranges: bytesage: 5068cache-control: max-age=14400cf-cache-status: HITcf-ray: a4b79cfbc98c8cea-FRAalt-svc: h3=":443"; ma=86400
<!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=viewport content="width=device-width,initial-scale=1"><title>Example Domain</title><style>html{color-scheme:light dark;background:light-dark(#eee,#222)}body{font:16px/1.6 system-ui,sans-serif;max-width:26em;margin:auto;padding:25vh 2em 2em;text-align:center}</style></head><body><p>This domain is for use in documentation examples without needing permission. This is not a service; avoid relying on it for testing and monitoring purposes.</p><script src=/s.js></script></body></html>(The first two lines are the verbose output on stderr; the rest is hit’s response on stdout.)
The server ignored the Accept hint and sent HTML anyway — servers do that. Accept is a
preference, not an instruction.
post --body sends the string as the request body, with no Content-Type — hit does not know
what the bytes are, so it says nothing and lets the server guess:
cargo run -- post https://httpbin.org/post --body "hello from hit"200 OKdate: Fri, 16 Oct 2026 14:04:55 GMTcontent-type: application/jsoncontent-length: 330server: gunicorn/19.9.0access-control-allow-origin: *access-control-allow-credentials: true
{ "args": {}, "data": "hello from hit", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Content-Length": "14", "Host": "httpbin.org", "X-Amzn-Trace-Id": "Root=1-6ad22f07-790dbf090fa5cc807fb1f472" }, "json": null, "origin": "171.113.217.182", "url": "https://httpbin.org/post"}httpbin.org/post echoes what you sent, so data is the body arriving intact and headers shows
what hit actually put on the wire.
Two limits worth knowing
Section titled “Two limits worth knowing”Redirects are followed, up to ten. That default is usually what you want and rarely what you are thinking about, so meet it once here:
cargo run -- get https://httpbin.org/redirect/10200 OKdate: Fri, 16 Oct 2026 14:22:18 GMTcontent-type: application/jsoncontent-length: 282server: gunicorn/19.9.0access-control-allow-origin: *access-control-allow-credentials: true
{ "args": {}, "headers": { "Accept": "*/*", "Host": "httpbin.org", "Referer": "https://httpbin.org/relative-redirect/1", "X-Amzn-Trace-Id": "Root=1-6ad2331a-14b99369471b16142696b892" }, "origin": "171.113.217.182", "url": "https://httpbin.org/get"}Ten hops later, the final response — hit followed every redirect and printed the body of
httpbin.org/get. Eleven:
cargo run -- get https://httpbin.org/redirect/11Error: error following redirect for url (https://httpbin.org/redirect/11)
Caused by: too many redirectsHTTPS works without extra features. reqwest 0.13’s default features include a TLS backend
(rustls), so https:// URLs work out of the box — every example above is HTTPS. The classic trap
is default-features = false, which silently drops TLS unless you re-add a feature, and the
failure only shows up at runtime when the first HTTPS request fails.
Exercises
Section titled “Exercises”- Give the timeout failure a human message: wrap
request.send()?with.with_context(|| format!("{url} did not answer within 10 seconds"))and compare the output with the bare version. - The last article’s exercises suggested a
--timeoutflag. Now it has something real to control: add the flag, thread the value through toDuration::from_secs, and re-run thehttpbin.org/delay/10check with--timeout 15— you should get the response instead of the timeout. - Add a
headsubcommand.client.head(&url)builds the request; the response prints as before, andtext()returns an empty string because a HEAD response has no body.
Where the code lives
Section titled “Where the code lives”This step’s end state is tagged
cli-ch04 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-ch04If your output differs from the article’s, checking out the tag and diffing it against your own tree shows which line drifted.
hit can now talk to the network, but its failures still print a raw Caused by: dump and exit 1
without a word about what the user should do about it. The next step gives the project a real error
type and messages worth reading.