How to retry a failed request
Short answer: reqwest 0.13 retries through a policy on the client — ClientBuilder::retry(reqwest::retry::for_host("example.com")) — and by default it only replays requests that died in a way the protocol calls safe. A 500 is not one of those. A classifier decides what is, and it’s also where the rule about which requests must never be retried lives.
The dependencies
Section titled “The dependencies”This picks up the fetch project from How to handle a non-200 status. 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"] }What the default policy retries
Section titled “What the default policy retries”Every Client carries a retry policy. The default one replays a request only when the connection itself failed in a way the specs call safe: an HTTP/2 GOAWAY with NO_ERROR, a REFUSED_STREAM reset, an HTTP/3 timeout. A 500 is a complete exchange to the transport, so it comes back as an ordinary response; reqwest::retry::never() turns even those replays off.
Setting a policy replaces the default — “providing a specific retry policy will override the default, and you will need to explicitly include that behavior”. A classifier that only looks at status codes drops the protocol replays too.
A classifier, and watching it retry
Section titled “A classifier, and watching it retry”The server answers 500 to the first two GETs and 200 after that, so a retry shows up as a second line in its log. It’s instrumentation; the client is the part to copy:
use std::io::{BufRead, BufReader, Write};use std::net::TcpListener;
#[tokio::main]async fn main() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}/", listener.local_addr().unwrap());
let server = std::thread::spawn(move || { let mut gets = 0; let mut total = 0; for stream in listener.incoming() { let stream = stream.unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut writer = stream; let mut request_line = String::new(); if reader.read_line(&mut request_line).unwrap() == 0 { continue; } total += 1; let method = request_line.split_whitespace().next().unwrap().to_owned(); if method == "GET" { gets += 1; } let status = match method.as_str() { "GET" if gets < 3 => "500 Internal Server Error", "POST" => "500 Internal Server Error", _ => "200 OK", }; println!("server: request {total} ({method}) -> {status}"); write!( writer, "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ) .unwrap(); writer.flush().unwrap(); if method == "POST" { break; } } });
let client = reqwest::Client::builder() .retry( reqwest::retry::for_host("127.0.0.1") .classify_fn(|req_rep| match (req_rep.method(), req_rep.status()) { (method, Some(status)) if method.is_idempotent() && status.is_server_error() => { req_rep.retryable() } _ => req_rep.success(), }), ) .build() .unwrap();
let get = client.get(&url).send().await.unwrap(); println!("client: GET -> {}", get.status()); let post = client.post(&url).send().await.unwrap(); println!("client: POST -> {}", post.status());
server.join().unwrap();}Run it with cargo run (cargo’s own Compiling and Finished lines are not shown here):
server: request 1 (GET) -> 500 Internal Server Errorserver: request 2 (GET) -> 500 Internal Server Errorserver: request 3 (GET) -> 200 OKclient: GET -> 200 OKserver: request 4 (POST) -> 500 Internal Server Errorclient: POST -> 500 Internal Server ErrorThree requests for one GET is max_retries_per_request — two retries on top of the original. for_host scopes the policy to one host, because a budget shared across every host makes no sense. The classifier decides what retryable() means, and Method::is_idempotent(), from the http crate reqwest re-exports, is the rule in one call: GET, HEAD, OPTIONS and TRACE are idempotent, and so are PUT and DELETE. POST and PATCH are not.
The other limit is the budget: a token bucket whose reserve allows ten retries a second for the first ten seconds, where every success deposits a token and every retry costs five. At the default max_extra_load(0.2) it settles at one retry per five successes; no_budget() removes it, which the docs call “NOT recommended”.
The requests you must not retry
Section titled “The requests you must not retry”The POST line is the point: the same 500, one request, no retry. Idempotent means the server ends in the same state however many copies arrive.
Two caveats. The same end state isn’t the same answer: a DELETE that timed out and was retried can come back 404 because the first one worked. And an API that issues idempotency keys makes a POST safe to retry, because the same key sent twice is one operation to the server.
One more silence: the policy replays a request by cloning it, and gives up when the body can’t be cloned. A streaming body is sent exactly once, and nothing in the response says the retry didn’t happen.
No backoff
Section titled “No backoff”These retries are immediate — the policy’s future is Ready(()), and the spot where a delay would go is still marked as unfinished work in the source:
// TODO? backoff futures...Nothing in the public API adds one. The budget is what keeps them from becoming a storm; when the server asks for a wait, a Retry-After header say, that’s a hand-written loop rather than this policy.
- How to handle a non-200 status — where the 500 comes from.
- How to set a timeout on a request — the other failure a retry can’t fix.
- How to make a GET request — the request this article retries.