Reqwest Error Handling and Status Code Checks Done Right
A 404 from reqwest is not an Err. Use error_for_status, classify failures with is_timeout and is_connect, set timeouts, read error bodies safely and return a typed error your callers can match on.

The most common reqwest bug is treating "the request completed" as "the request succeeded". HTTP errors are valid responses. This post shows the checks that distinguish them, the timeouts that prevent hangs, and a typed error you can build once and reuse. It compiles on Rust 1.90 with edition 2024, reqwest 0.12, serde 1 and Tokio 1.47.
Ok does not mean success
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let res = reqwest::get("https://httpbin.org/status/404").await?;
println!("status = {}", res.status());
let checked = res.error_for_status();
println!("is_err = {}", checked.is_err());
Ok(())
}status = 404 Not Found
is_err = trueThe first ? passed because the server answered. error_for_status then converts 4xx and 5xx into an Err and passes 1xx to 3xx through. Use error_for_status_ref(&self) if you want to inspect the body after.
Timeouts first
use std::time::Duration;
fn client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(10))
.user_agent("my-service/1.0")
.build()
}By default there is none, so a server that accepts a connection and never answers hangs the call forever (tasks that never finish). timeout bounds the whole request including reading the body; connect_timeout bounds only the handshake. Per-request overrides use RequestBuilder::timeout. Build the client once and reuse it, since it holds the connection pool.
Classifying failures
fn describe(e: &reqwest::Error) -> &'static str {
if e.is_timeout() {
"timeout"
} else if e.is_connect() {
"could not connect"
} else if e.is_decode() {
"response was not what we expected (JSON or UTF-8)"
} else if e.is_status() {
"server returned an error status"
} else if e.is_builder() {
"bad request construction (URL or headers)"
} else {
"other transport error"
}
}These flags let you choose a reaction. Timeouts and connect errors are transient and often retryable; decode and builder errors are bugs or contract changes and retrying will not help.
A typed error
use serde::de::DeserializeOwned;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("request timed out")]
Timeout,
#[error("could not reach the service")]
Unreachable(#[source] reqwest::Error),
#[error("service answered {status}: {body}")]
Status { status: u16, body: String },
#[error("unexpected response shape")]
Decode(#[source] reqwest::Error),
}
pub async fn get_json<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T, ApiError> {
let res = client.get(url).send().await.map_err(|e| {
if e.is_timeout() { ApiError::Timeout } else { ApiError::Unreachable(e) }
})?;
let status = res.status();
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
let body: String = body.chars().take(200).collect();
return Err(ApiError::Status { status: status.as_u16(), body });
}
res.json::<T>().await.map_err(|e| if e.is_timeout() { ApiError::Timeout } else { ApiError::Decode(e) })
}The error body is read before it is discarded, truncated so a huge HTML error page does not flood logs, and carried in the error. Callers match on the variants instead of parsing strings. If the service has a documented error JSON, decode it instead of keeping raw text (problem details).
Retry only what is safe
fn retryable(err: &ApiError) -> bool {
match err {
ApiError::Timeout | ApiError::Unreachable(_) => true,
ApiError::Status { status, .. } => matches!(status, 502 | 503 | 504 | 429),
ApiError::Decode(_) => false,
}
}Only retry requests that are idempotent (GET, PUT, DELETE) or protected by an idempotency key (idempotency keys), use exponential backoff with jitter, and honor Retry-After on 429 and 503 (rate limit aware clients).
Checklist
- Build one
Clientwith connect and overall timeouts. - Call
error_for_statusor checkstatus().is_success()on every response. - Map errors into your own enum at the edge.
- Truncate and log error bodies; never include secrets in messages.
- Decide retry per variant, not per call site.


