Skip to content
Select theme

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.

This picks up the fetch project from How to handle a non-200 status. 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"] }

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.

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:

Run it with cargo run (cargo’s own Compiling and Finished lines are not shown here):

server: request 1 (GET) -> 500 Internal Server Error
server: request 2 (GET) -> 500 Internal Server Error
server: request 3 (GET) -> 200 OK
client: GET -> 200 OK
server: request 4 (POST) -> 500 Internal Server Error
client: POST -> 500 Internal Server Error

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

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.