Retries, Timeouts & Backoff
Make outbound HTTP calls resilient with timeouts, exponential backoff, jitter, and idempotency awareness.
Recipe
Quick-reference recipe card - copy-paste ready.
async fn with_retry<F, Fut, T, E>(mut f: F, max: u32) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
let mut attempt = 0;
loop {
match f().await {
Ok(v) => return Ok(v),
Err(e) if attempt >= max => return Err(e),
Err(_) => {
let delay = std::time::Duration::from_millis(100 * 2u64.pow(attempt));
tokio::time::sleep(delay).await;
attempt += 1;
}
}
}
}When to reach for this: Any call over the internet that can fail transiently - 502, connection reset, timeout.
Working Example
use reqwest::StatusCode;
use std::time::Duration;
async fn fetch_with_policy(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
let mut attempt = 0u32;
loop {
let result = client.get(url).send().await;
match result {
Ok(resp) if resp.status().is_success() => return resp.text().await,
Ok(resp) if resp.status() == StatusCode::TOO_MANY_REQUESTS => {
let retry_after = resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2u64.pow(attempt.min(5)));
tokio::time::sleep(Duration::from_secs(retry_after)).await;
}
Ok(_) | Err(_) if attempt < 3 => {
let base = 100u64 * 2u64.pow(attempt);
let jitter = rand::random::<u64>() % 50;
tokio::time::sleep(Duration::from_millis(base + jitter)).await;
}
Ok(resp) => return resp.error_for_status().map(|_| unreachable!()),
Err(e) if e.is_timeout() && attempt < 3 => {}
Err(e) => return Err(e),
}
attempt += 1;
}
}What this demonstrates:
- Exponential backoff capped at attempt 5.
- Jitter reduces synchronized retry storms.
- Honors
Retry-Afteron 429 responses. - Distinguishes timeout errors from other failures.
Deep Dive
How It Works
- Timeouts bound wait time; retries recover transient failures.
- Backoff spreads load after outages.
- Only idempotent methods (GET, PUT with idempotency key) safe for blind retries.
Retry Policy Table
| Condition | Retry? |
|---|---|
| Connect timeout | Yes with backoff |
| 500/502/503/504 | Yes limited |
| 400/401/404 | No |
| POST without idempotency | No |
Rust Notes
// reqwest-middleware + reqwest-retry crates for production policiesGotchas
- Retrying POST payments - Double charge risk. Fix: Idempotency-Key header and server-side dedup.
- No max attempts - Infinite retry hammers failing dependency. Fix: Cap attempts; circuit breaker after threshold.
- Same timeout client-wide - Fast metadata vs slow upload mixed. Fix: Per-request overrides.
- Zero jitter - Thundering herd on recovery. Fix: Full jitter on backoff delay.
- Retrying non-idempotent reads that mutate - Some GETs trigger side effects. Fix: Know API contract.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Circuit breaker (tower) | Sustained outage | Rare blips only |
| Queue + worker | Absorb spikes async | Need synchronous response |
| Fallback cache | Degraded read mode | Strong consistency required |
FAQs
How many retries?
3-5 for idempotent reads; 0-1 for writes unless idempotency guaranteed.
Client timeout values?
Connect 2-5s, total 10-30s depending on SLA.
gRPC retries?
tonic client middleware or service mesh policy.
Log retries?
Warn on retry with attempt count and upstream host.
hedging?
Send duplicate request after delay - only for safe read paths.
bulkhead?
Limit concurrent outbound calls per dependency.
test retries?
wiremock sequence returning 503 then 200.
DNS failures?
Retry with backoff; cache DNS at OS level.
global rate limit?
Stop retries when local budget exhausted.
Observability?
Metric upstream_retries_total{dependency}.
Related
- HTTP Clients (reqwest) - Client setup
- Networking Best Practices - Policy rules
- API Design Basics - Idempotency
- Observability - Metrics and traces
- Rate Limiting & Abuse Prevention - 429 handling
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.