Skip to content
Select theme

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.

This picks up the fetch project from How to read a response header. The same two crates, no features beyond reqwest’s defaults:

Cargo.toml
[dependencies]
reqwest = "0.13.5"
tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] }

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:

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: false
https://httpbin.org/status/500
500 Internal Server Error
is_success: false
is_client_error: false
is_server_error: true

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

Response::error_for_status consumes the response and converts a 4xx or 5xx into Err, handing back the response untouched for anything else:

HTTP status client error (404 Not Found) for url (https://httpbin.org/status/404)
is_status: true
status: 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".

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:

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.