Rust HTTP client
last modified November 13, 2026
In this article we send HTTP requests in Rust with the reqwest crate.
The Hypertext Transfer Protocol (HTTP) is the protocol of the web and of most web APIs. A client sends a request with a method such as GET or POST, a URL, headers, and an optional body; the server answers with a status code, headers, and a body.
The Rust standard library has TCP sockets but no HTTP client.
reqwest is the most popular high-level HTTP client crate. It
is built on hyper and Tokio, supports HTTPS, JSON, redirects,
cookies, and proxies, and offers both an async API and a blocking one.
The examples were tested with Rust 1.97, the 2024 edition, reqwest
0.13.5, Tokio 1.54.1, and serde_json 1.0.154. They are run as Cargo
projects with cargo run -q and send requests to
example.com and to httpbin.org, a service that
echoes requests back as JSON.
Setting up the project
Since version 0.13, several reqwest features are opt-in. We enable the blocking client, JSON bodies, query strings, and form bodies. The async examples need Tokio, and serde parses the JSON responses into structs.
[package]
name = "http_example"
version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.13.5", features = ["blocking", "form", "json", "query"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
tokio = { version = "1.54.1", features = ["full"] }
The tokio, serde, and serde_json
crates are added with cargo add as well. Without the
query feature, the query method does not exist,
and the compiler reports error E0599. HTTPS works out of the box; the
default TLS backend in 0.13 is rustls.
$ cargo add reqwest --features blocking,json,query,form
Updating crates.io index
Adding reqwest v0.13.5 to dependencies
Features:
+ __rustls
+ __rustls-aws-lc-rs
+ __tls
+ blocking
+ charset
+ default-tls
+ form
+ http2
+ json
+ query
+ rustls
+ system-proxy
- __native-tls
- __native-tls-alpn
- brotli
- cookies
- deflate
- gzip
- hickory-dns
- http3
- multipart
- native-tls
- native-tls-no-alpn
- native-tls-vendored
- native-tls-vendored-no-alpn
- rustls-no-provider
- socks
- stream
- zstd
Updating crates.io index
Locking 177 packages to latest Rust 1.97.1 compatible versions
A blocking GET request
The blocking API is the simplest way to send a request. It needs no async runtime in our code.
fn main() -> Result<(), reqwest::Error> {
let resp = reqwest::blocking::get("https://example.com")?;
println!("status: {}", resp.status());
println!("version: {:?}", resp.version());
println!("content-type: {:?}", resp.headers().get("content-type"));
let body = resp.text()?;
println!("length: {}", body.len());
if let Some(start) = body.find("<title>")
&& let Some(end) = body.find("</title>")
{
println!("title: {}", &body[start + 7..end]);
}
Ok(())
}
We download the home page of example.com.
let resp = reqwest::blocking::get("https://example.com")?;
The get function sends a GET request and waits for the
response headers. It is a shortcut for creating a client and sending one
request. The function fails only when the request cannot be made; an
error status such as 404 is still a successful response.
println!("status: {}", resp.status());
println!("version: {:?}", resp.version());
The status code and the protocol version are known as soon as the headers arrive. The connection used HTTP/2, negotiated during the TLS handshake.
let body = resp.text()?;
The text method reads the whole body and decodes it as text,
using the charset from the Content-Type header. It consumes
the response.
$ cargo run -q
status: 200 OK
version: HTTP/2.0
content-type: Some("text/html; charset=utf-8")
length: 577
title: Example Domain
An async GET request
The async API has the same shape, with an .await after each
operation that waits for the network.
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let resp = reqwest::get("https://httpbin.org/robots.txt").await?;
println!("status: {}", resp.status());
println!("length: {:?}", resp.content_length());
let body = resp.text().await?;
print!("{body}");
Ok(())
}
We fetch a small text file.
let resp = reqwest::get("https://httpbin.org/robots.txt").await?;
The async reqwest::get function is used inside a Tokio
runtime. The blocking client actually runs an async client on a
background thread, so it must not be used inside async code.
println!("length: {:?}", resp.content_length());
The content_length method returns the size announced by the
server, if any.
$ cargo run -q status: 200 OK length: Some(30) User-agent: * Disallow: /deny
Status codes
The status code tells whether a request succeeded. reqwest represents it
with the StatusCode type.
use reqwest::StatusCode;
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::new();
for code in [200, 404, 503] {
let url = format!("https://httpbin.org/status/{code}");
let resp = client.get(&url).send()?;
let status = resp.status();
println!(
"{} success={} client_error={} server_error={}",
status,
status.is_success(),
status.is_client_error(),
status.is_server_error()
);
if status == StatusCode::NOT_FOUND {
println!(" the page does not exist");
}
}
let resp = client.get("https://httpbin.org/status/404").send()?;
match resp.error_for_status() {
Ok(r) => println!("ok: {}", r.status()),
Err(e) => println!("error: {e} (status {:?})", e.status()),
}
Ok(())
}
We request three status codes from httpbin.
let client = reqwest::blocking::Client::new();
A Client holds a connection pool and its configuration.
Creating one per program and reusing it is much faster than calling
reqwest::get repeatedly, because connections are kept alive
between requests.
status.is_success(), status.is_client_error(), status.is_server_error()
These methods test the class of the code: 2xx, 4xx, and 5xx. Specific
codes can be compared with constants such as
StatusCode::NOT_FOUND.
match resp.error_for_status() {
The error_for_status method turns 4xx and 5xx responses into
errors. It is handy in a chain of ? operators.
$ cargo run -q 200 OK success=true client_error=false server_error=false 404 Not Found success=false client_error=true server_error=false the page does not exist 503 Service Unavailable success=false client_error=false server_error=true error: HTTP status client error (404 Not Found) for url (https://httpbin.org/status/404) (status Some(404))
Request and response headers
Headers carry metadata about requests and responses.
use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
use serde_json::Value;
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::new();
let resp = client
.get("https://httpbin.org/headers")
.header(USER_AGENT, "rust-http-example/1.0")
.header(ACCEPT, "application/json")
.header("X-Api-Version", "2")
.send()?;
let v: Value = resp.json()?;
let sent = &v["headers"];
for name in ["User-Agent", "Accept", "X-Api-Version", "Host"] {
println!("{name}: {}", sent[name]);
}
let resp = client.get("https://httpbin.org/response-headers?Cache-Control=no-cache").send()?;
let headers: &HeaderMap = resp.headers();
println!("cache-control: {:?}", headers.get("cache-control"));
println!("content-type: {:?}", headers.get("content-type").and_then(|h| h.to_str().ok()));
println!("has etag: {}", headers.contains_key("etag"));
println!("{} headers in total", headers.len());
let value = HeaderValue::from_str("bad\nvalue");
println!("{value:?}");
Ok(())
}
We send custom headers and read the headers of a response.
.header(USER_AGENT, "rust-http-example/1.0")
.header(ACCEPT, "application/json")
.header("X-Api-Version", "2")
The header method adds a header to the request. Standard
header names are available as constants; custom ones can be strings.
reqwest sends no User-Agent by default; many APIs require
one.
let headers: &HeaderMap = resp.headers();
println!("cache-control: {:?}", headers.get("cache-control"));
Header names are case-insensitive. The get method returns an
Option<&HeaderValue>; to_str converts
a value to a string slice if it is visible ASCII.
let value = HeaderValue::from_str("bad\nvalue");
A header value cannot contain a newline; otherwise an attacker could inject extra headers. The constructor rejects such values.
$ cargo run -q
User-Agent: "rust-http-example/1.0"
Accept: "application/json"
X-Api-Version: "2"
Host: "httpbin.org"
cache-control: Some("no-cache")
content-type: Some("application/json")
has etag: false
8 headers in total
Err(InvalidHeaderValue)
Query parameters
The query method appends URL-encoded parameters to the
request URL.
use serde::Serialize;
use serde_json::Value;
#[derive(Serialize)]
struct Search<'a> {
q: &'a str,
page: u32,
#[serde(skip_serializing_if = "Option::is_none")]
lang: Option<&'a str>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let resp = client
.get("https://httpbin.org/get")
.query(&[("name", "John Doe"), ("occupation", "gardener & cook")])
.send()?;
println!("{}", resp.url());
let v: Value = resp.json()?;
println!("{}", v["args"]);
let params = Search { q: "rust async", page: 2, lang: None };
let resp = client.get("https://httpbin.org/get").query(¶ms).send()?;
println!("{}", resp.url());
let mut url = reqwest::Url::parse("https://httpbin.org/get?page=1")?;
url.query_pairs_mut().append_pair("sort", "date");
println!("{url}");
Ok(())
}
We pass parameters as an array of pairs and as a struct.
.query(&[("name", "John Doe"), ("occupation", "gardener & cook")])
The values are encoded: the space becomes + and the
ampersand %26. Building the query string by hand with
format! would get this wrong.
let params = Search { q: "rust async", page: 2, lang: None };
let resp = client.get("https://httpbin.org/get").query(¶ms).send()?;
Any type that implements Serialize can be the query. The
skipped None field is not sent.
let mut url = reqwest::Url::parse("https://httpbin.org/get?page=1")?;
url.query_pairs_mut().append_pair("sort", "date");
The re-exported Url type can also build URLs directly. Its
parse error is not a reqwest::Error, so main
returns Box<dyn Error>.
$ cargo run -q
https://httpbin.org/get?name=John+Doe&occupation=gardener+%26+cook
{"name":"John Doe","occupation":"gardener & cook"}
https://httpbin.org/get?q=rust+async&page=2
https://httpbin.org/get?page=1&sort=date
JSON responses
With the json feature, the json method
deserializes the response body into any type that implements
Deserialize.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Slide {
title: String,
#[serde(default)]
items: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Slideshow {
author: String,
title: String,
slides: Vec<Slide>,
}
#[derive(Debug, Deserialize)]
struct Document {
slideshow: Slideshow,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let doc: Document = reqwest::get("https://httpbin.org/json").await?.json().await?;
let show = doc.slideshow;
println!("{} by {}", show.title, show.author);
for (i, slide) in show.slides.iter().enumerate() {
println!("{}. {} ({} items)", i + 1, slide.title, slide.items.len());
}
Ok(())
}
The httpbin /json endpoint returns a sample slideshow
document.
let doc: Document = reqwest::get("https://httpbin.org/json").await?.json().await?;
The target type is given by the annotation. Fields not declared in the structs are ignored. A body that does not match the structs is reported as a decode error.
#[serde(default)] items: Vec<String>,
The first slide has no items key; the default makes it an
empty vector. Working with JSON is covered in more detail in
Rust Serde JSON.
$ cargo run -q Sample Slide Show by Yours Truly 1. Wake up to WonderWidgets! (0 items) 2. Overview (2 items)
Posting JSON
The json method of the request builder serializes a value
into the request body.
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Serialize)]
struct NewUser {
name: String,
email: String,
roles: Vec<String>,
}
#[derive(Deserialize)]
struct Echo {
json: Value,
headers: Value,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
let user = NewUser {
name: "Anna".into(),
email: "anna@example.com".into(),
roles: vec!["editor".into()],
};
let resp = client.post("https://httpbin.org/post").json(&user).send().await?;
println!("status: {}", resp.status());
let echo: Echo = resp.json().await?;
println!("content-type: {}", echo.headers["Content-Type"]);
println!("server received: {}", echo.json);
Ok(())
}
We create a user by posting JSON. The httpbin /post endpoint
echoes back what it received.
let resp = client.post("https://httpbin.org/post").json(&user).send().await?;
The post method starts a POST request. The json
method serializes the struct and sets the Content-Type
header to application/json.
#[derive(Deserialize)]
struct Echo {
json: Value,
headers: Value,
}
We deserialize only the two parts of the echo that interest us. Partly
typed structs with Value fields are useful for loosely
structured responses.
$ cargo run -q
status: 200 OK
content-type: "application/json"
server received: {"email":"anna@example.com","name":"Anna","roles":["editor"]}
Forms and other methods
HTML forms are sent as URL-encoded bodies. The client also has methods for PUT, PATCH, DELETE, and HEAD requests.
use serde_json::Value;
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::new();
let params = [("username", "jdoe"), ("comment", "Hello, world!")];
let resp = client.post("https://httpbin.org/post").form(¶ms).send()?;
let v: Value = resp.json()?;
println!("content-type: {}", v["headers"]["Content-Type"]);
println!("form: {}", v["form"]);
let resp = client
.put("https://httpbin.org/put")
.header("Content-Type", "text/plain")
.body("plain text body")
.send()?;
let v: Value = resp.json()?;
println!("put data: {}", v["data"]);
let resp = client.delete("https://httpbin.org/delete").send()?;
println!("delete: {}", resp.status());
Ok(())
}
We post a form, put a text body, and send a DELETE request.
let params = [("username", "jdoe"), ("comment", "Hello, world!")];
let resp = client.post("https://httpbin.org/post").form(¶ms).send()?;
The form method encodes the pairs and sets the content type
to application/x-www-form-urlencoded. File uploads need the
multipart feature instead.
.header("Content-Type", "text/plain")
.body("plain text body")
The body method sets a raw body from a string or bytes.
$ cargo run -q
content-type: "application/x-www-form-urlencoded"
form: {"comment":"Hello, world!","username":"jdoe"}
put data: "plain text body"
delete: 200 OK
Timeouts
By default, reqwest waits for a response indefinitely. Network code should set timeouts.
use std::time::{Duration, Instant};
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.connect_timeout(Duration::from_secs(5))
.user_agent("rust-http-example/1.0")
.build()?;
let start = Instant::now();
match client.get("https://httpbin.org/delay/5").send() {
Ok(resp) => println!("status: {}", resp.status()),
Err(e) if e.is_timeout() => {
println!("timed out after {:.1}s", start.elapsed().as_secs_f64())
}
Err(e) => println!("error: {e}"),
}
let resp = client
.get("https://httpbin.org/delay/1")
.timeout(Duration::from_secs(10))
.send()?;
println!("status: {}", resp.status());
Ok(())
}
The httpbin /delay/5 endpoint answers after five seconds.
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.connect_timeout(Duration::from_secs(5))
.user_agent("rust-http-example/1.0")
.build()?;
The ClientBuilder configures a client. The total timeout
covers the whole request, from connecting to reading the body; the
connect timeout covers only establishing the connection. The user agent
is sent with every request.
Err(e) if e.is_timeout() => {
The is_timeout method identifies timeout errors.
.timeout(Duration::from_secs(10))
A timeout set on a single request overrides the client's.
$ cargo run -q timed out after 2.0s status: 200 OK
Concurrent requests
The async client can run many requests at once. The total time is that of the slowest request, not the sum of all of them.
use std::time::Instant;
use tokio::task::JoinSet;
async fn fetch_len(client: reqwest::Client, url: String) -> Result<(String, usize), reqwest::Error> {
let body = client.get(&url).send().await?.error_for_status()?.text().await?;
Ok((url, body.len()))
}
#[tokio::main]
async fn main() {
let client = reqwest::Client::new();
let urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/status/500",
"https://example.com",
];
let start = Instant::now();
let mut set = JoinSet::new();
for url in urls {
set.spawn(fetch_len(client.clone(), url.to_string()));
}
let mut ok = 0;
while let Some(res) = set.join_next().await {
match res.unwrap() {
Ok((url, len)) => {
ok += 1;
println!("{url}: {len} bytes")
}
Err(e) => println!("failed: {e}"),
}
}
println!("{ok} of {} succeeded in {:.0}s", urls.len(), start.elapsed().as_secs_f64());
}
We fetch four URLs concurrently; one of them returns an error.
set.spawn(fetch_len(client.clone(), url.to_string()));
Each request runs as a separate task. Cloning a Client is
cheap: the clones share the same connection pool.
let body = client.get(&url).send().await?.error_for_status()?.text().await?;
The ? operators chain the steps; any failure, including a
500 status, ends the function with an error.
while let Some(res) = set.join_next().await {
The results arrive in completion order. The requests took one and two
seconds, and the whole run took two. Tasks and JoinSet are
explained in Rust async and Tokio.
$ cargo run -q https://example.com: 577 bytes failed: HTTP status server error (500 Internal Server Error) for url (https://httpbin.org/status/500) https://httpbin.org/delay/1: 232 bytes https://httpbin.org/delay/2: 232 bytes 3 of 4 succeeded in 2s
Redirects
reqwest follows up to ten redirects by default. The redirect policy can be changed.
use reqwest::redirect::Policy;
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::new();
let resp = client.get("https://httpbin.org/redirect/3").send()?;
println!("{} {}", resp.status(), resp.url());
let no_follow = reqwest::blocking::Client::builder()
.redirect(Policy::none())
.build()?;
let resp = no_follow.get("https://httpbin.org/redirect/3").send()?;
println!("{} location: {:?}", resp.status(), resp.headers().get("location"));
let limited = reqwest::blocking::Client::builder()
.redirect(Policy::limited(2))
.build()?;
match limited.get("https://httpbin.org/redirect/3").send() {
Ok(r) => println!("{}", r.status()),
Err(e) => println!("redirect error: {}", e.is_redirect()),
}
Ok(())
}
The httpbin /redirect/3 endpoint redirects three times
before arriving at /get.
println!("{} {}", resp.status(), resp.url());
After following redirects, url returns the final URL.
.redirect(Policy::none())
With no redirect policy, the client returns the 302 response itself, and
the target can be read from the Location header.
.redirect(Policy::limited(2))
A limit lower than the number of redirects makes the request fail with a redirect error.
$ cargo run -q
200 OK https://httpbin.org/get
302 Found location: Some("/relative-redirect/2")
redirect error: true
Downloading a file
Large bodies should not be read into memory at once. The
chunk method returns the body piece by piece as it arrives.
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = "https://httpbin.org/image/png";
let mut resp = reqwest::get(url).await?.error_for_status()?;
println!("content-type: {:?}", resp.headers().get("content-type"));
println!("content-length: {:?}", resp.content_length());
let mut file = File::create("pig.png").await?;
let mut total = 0;
let mut chunks = 0;
while let Some(chunk) = resp.chunk().await? {
file.write_all(&chunk).await?;
total += chunk.len();
chunks += 1;
}
file.flush().await?;
println!("saved {total} bytes in {chunks} chunks");
let header = std::fs::read("pig.png")?;
println!("signature: {}", String::from_utf8_lossy(&header[1..4]));
std::fs::remove_file("pig.png")?;
Ok(())
}
We download a PNG image and write it to a file.
let mut resp = reqwest::get(url).await?.error_for_status()?;
Without error_for_status, an error page would be saved as
the image.
while let Some(chunk) = resp.chunk().await? {
file.write_all(&chunk).await?;
Each chunk is a Bytes buffer. Memory usage stays small
regardless of the file size. The number of chunks depends on the network.
println!("signature: {}", String::from_utf8_lossy(&header[1..4]));
Every PNG file starts with a fixed signature that contains the letters PNG, which confirms the download.
$ cargo run -q
content-type: Some("image/png")
content-length: Some(8090)
saved 8090 bytes in 2 chunks
signature: PNG
Authentication
The request builder has helpers for the two most common authentication schemes.
use serde_json::Value;
fn main() -> Result<(), reqwest::Error> {
let client = reqwest::blocking::Client::new();
let url = "https://httpbin.org/basic-auth/john/secret";
let resp = client.get(url).send()?;
println!("no credentials: {}", resp.status());
let resp = client.get(url).basic_auth("john", Some("wrong")).send()?;
println!("wrong password: {}", resp.status());
let resp = client.get(url).basic_auth("john", Some("secret")).send()?;
println!("correct: {} {}", resp.status(), resp.json::<Value>()?);
let resp = client
.get("https://httpbin.org/bearer")
.bearer_auth("my-api-token")
.send()?;
println!("bearer: {} {}", resp.status(), resp.json::<Value>()?);
Ok(())
}
The httpbin /basic-auth/john/secret endpoint accepts only
the given user name and password.
let resp = client.get(url).basic_auth("john", Some("secret")).send()?;
The basic_auth method adds an Authorization
header with the Base64-encoded credentials. Basic authentication is
only safe over HTTPS.
.bearer_auth("my-api-token")
The bearer_auth method sends a token, as used by OAuth 2 and
most API keys. Tokens should come from the environment or a
configuration file, not from the source code.
$ cargo run -q
no credentials: 401 Unauthorized
wrong password: 401 Unauthorized
correct: 200 OK {"authenticated":true,"authorized":true,"user":"john"}
bearer: 200 OK {"authenticated":true,"token":"my-api-token"}
Handling errors
A reqwest::Error can come from building the request, from
the network, or from decoding the body. Its methods tell the cases apart,
and its source chain explains the cause.
use std::error::Error;
fn main() {
let client = reqwest::blocking::Client::new();
let urls = [
"https://no-such-host.invalid/",
"http://127.0.0.1:9/",
"not a url",
"https://httpbin.org/html",
];
for url in urls {
match client.get(url).send().and_then(|r| r.json::<serde_json::Value>()) {
Ok(v) => println!("ok: {v}"),
Err(e) => {
println!("{url}");
println!(" connect={} builder={} decode={}", e.is_connect(), e.is_builder(), e.is_decode());
let mut source = e.source();
while let Some(s) = source {
println!(" caused by: {s}");
source = s.source();
}
}
}
}
}
We provoke four kinds of failures.
match client.get(url).send().and_then(|r| r.json::<serde_json::Value>()) {
The and_then combinator parses the body only when the
request succeeded.
let mut source = e.source();
while let Some(s) = source {
println!(" caused by: {s}");
source = s.source();
}
The top-level message is short; the details are in the chain of
underlying errors. The .invalid top-level domain is
reserved and never resolves, port 9 on the local machine refuses the
connection, a string without a scheme is not a valid URL, and an HTML
page is not valid JSON.
$ cargo run -q https://no-such-host.invalid/ connect=true builder=false decode=false caused by: client error (Connect) caused by: dns error caused by: error resolving DNS caused by: failed to lookup address information: Name or service not known http://127.0.0.1:9/ connect=true builder=false decode=false caused by: client error (Connect) caused by: tcp connect error caused by: Connection refused (os error 111) not a url connect=false builder=true decode=false caused by: relative URL without a base https://httpbin.org/html connect=false builder=false decode=true caused by: expected value at line 1 column 1
Source
In this article we have sent HTTP requests in Rust with reqwest. JSON processing is covered in Rust Serde JSON, and the async runtime in Rust async and Tokio.
Author
List all Rust tutorials.