How to handle a non-200 status
Short answer: reqwest hands you a Response for a 404 or a 500 — the request itself succeeded, whatever the server said. response.error_for_status() turns a 4xx or 5xx into a reqwest::Error, and when the API puts the reason in the body, call error_for_status_ref() first so the response survives long enough to read it.
The dependencies
Section titled “The dependencies”This picks up the fetch project from How to read a response header. The same two crates, no features beyond reqwest’s defaults:
[dependencies]reqwest = "0.13.5"tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] }A 404 is a successful request
Section titled “A 404 is a successful request”reqwest::get resolves to Ok for any status the server manages to send back. The transport did its job: DNS, TCP, TLS, the request, the response. A 404 Not Found is the server answering, so it comes back as Ok(response) with the status inside:
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { for url in [ "https://httpbin.org/status/404", "https://httpbin.org/status/500", ] { let response = reqwest::get(url).await?; let status = response.status(); println!("{url}"); println!(" {status}"); println!(" is_success: {}", status.is_success()); println!(" is_client_error: {}", status.is_client_error()); println!(" is_server_error: {}", status.is_server_error()); } Ok(())}cargo run prints (cargo’s own Compiling and Finished lines are not shown):
https://httpbin.org/status/404 404 Not Found is_success: false is_client_error: true is_server_error: falsehttps://httpbin.org/status/500 500 Internal Server Error is_success: false is_client_error: false is_server_error: trueStatusCode answers the classification questions directly — is_success, is_client_error, is_server_error — so code that only cares about “did this work” branches on one of those instead of comparing numbers. A 3xx isn’t an error either: error_for_status converts 4xx and 5xx and nothing else, so a 304 Not Modified, which carries no Location to follow, passes straight through as Ok.
Turning the status into an error
Section titled “Turning the status into an error”Response::error_for_status consumes the response and converts a 4xx or 5xx into Err, handing back the response untouched for anything else:
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let response = reqwest::get("https://httpbin.org/status/404").await?; match response.error_for_status() { Ok(response) => println!("ok: {}", response.status()), Err(error) => { println!("{error}"); println!("is_status: {}", error.is_status()); println!("status: {:?}", error.status()); } } Ok(())}HTTP status client error (404 Not Found) for url (https://httpbin.org/status/404)is_status: truestatus: Some(404)The message names the status and the URL, so an error logged from deep inside a call chain still says which request failed. Error::status returns Option<StatusCode> — None for every error that did not come from a response, which is how a status error is told apart from the other kinds. A timeout, a refused connection, a TLS failure: those are Request-kind errors, and is_timeout() from How to set a timeout on a request is the check for those. error.is_status() is the check for this one.
The catch is that error_for_status takes self. The body is dropped with the response, so an API that explains itself in the body loses its explanation: GitHub answers a 404 for a missing repository with a JSON body whose message field reads "Not Found".
Reading the error body anyway
Section titled “Reading the error body anyway”error_for_status_ref borrows instead of consuming. It returns the same error, but the response stays alive, so the body is still there to read:
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let response = reqwest::get("https://httpbin.org/status/404").await?; if let Err(error) = response.error_for_status_ref() { let body = response.text().await?; println!("{error}"); println!("body: {body:?}"); } Ok(())}HTTP status client error (404 Not Found) for url (https://httpbin.org/status/404)body: ""httpbin’s /status endpoint sends no body, so the text comes back empty; against an API that explains its errors in the body, that line is where the reason shows up. The other order works too — read .text() first and classify the status afterwards — but then the classification is hand-rolled, and error_for_status_ref keeps it in the one place that already knows the rules. Retrying a request that failed this way is the next problem, and it starts from the status code this hands you.
- How to set a timeout on a request — a different kind of error from the same client, and how to tell them apart.
- How to make a GET request — where the response and its status come from.
- How to deserialize JSON into a struct — what to do with the body once you’ve decided to read it.