Skip to content
Select theme

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:

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

Replace the contents of src/main.rs:

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(())
}
Terminal window
cargo run -- get https://example.org
200 OK
date: Fri, 16 Oct 2026 14:04:09 GMT
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Tue, 13 Oct 2026 22:05:48 GMT
allow: GET, HEAD
accept-ranges: bytes
age: 5046
cache-control: max-age=14400
cf-cache-status: HIT
cf-ray: a4b79c6f09f29ac1-FRA
alt-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.

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.

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:

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

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

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:

Terminal window
cargo run -- get https://httpbin.org/delay/10
Error: error sending request for url (https://httpbin.org/delay/10)
Caused by:
operation timed out

The wall clock read about 10.5 seconds. The exit code is 1, the chain says why, and the program ended instead of hanging.

The -H loop from the last article now does something. Run it verbosely to see both sides:

Terminal window
cargo run -- get https://example.org -H "Accept: text/plain" -v
GET https://example.org
header: Accept: text/plain
200 OK
date: Fri, 16 Oct 2026 14:04:31 GMT
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Tue, 13 Oct 2026 22:05:48 GMT
allow: GET, HEAD
accept-ranges: bytes
age: 5068
cache-control: max-age=14400
cf-cache-status: HIT
cf-ray: a4b79cfbc98c8cea-FRA
alt-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:

Terminal window
cargo run -- post https://httpbin.org/post --body "hello from hit"
200 OK
date: Fri, 16 Oct 2026 14:04:55 GMT
content-type: application/json
content-length: 330
server: gunicorn/19.9.0
access-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.

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:

Terminal window
cargo run -- get https://httpbin.org/redirect/10
200 OK
date: Fri, 16 Oct 2026 14:22:18 GMT
content-type: application/json
content-length: 282
server: gunicorn/19.9.0
access-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:

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

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

  1. 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.
  2. The last article’s exercises suggested a --timeout flag. Now it has something real to control: add the flag, thread the value through to Duration::from_secs, and re-run the httpbin.org/delay/10 check with --timeout 15 — you should get the response instead of the timeout.
  3. Add a head subcommand. client.head(&url) builds the request; the response prints as before, and text() returns an empty string because a HEAD response has no body.

This step’s end state is tagged cli-ch04 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-ch04

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

← Back to the track overview