use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use alkhttp::client::{HttpClientConfig, RetryConfig, SharedHttpClient};
use std::sync::Mutex;
struct ScriptedResponder {
addr: std::net::SocketAddr,
hits: Arc<AtomicU32>,
}
impl ScriptedResponder {
async fn spawn(script: Vec<&'static str>) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("responder binds");
let addr = listener.local_addr().expect("responder address");
let hits = Arc::new(AtomicU32::new(0));
let script = Arc::new(Mutex::new(script));
let hits_loop = Arc::clone(&hits);
let script_loop = Arc::clone(&script);
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
hits_loop.fetch_add(1, Ordering::SeqCst);
let head = {
let mut queue = script_loop.lock().unwrap_or_else(|e| e.into_inner());
if queue.len() <= 1 {
queue
.first()
.copied()
.unwrap_or("HTTP/1.1 500 Internal Server Error")
} else {
queue.remove(0)
}
};
let body = "";
let response = format!(
"{head}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let _ = tokio::io::AsyncWriteExt::write_all(&mut sock, response.as_bytes()).await;
tokio::io::AsyncWriteExt::shutdown(&mut sock).await.ok();
}
});
Self { addr, hits }
}
fn hits(&self) -> u32 {
self.hits.load(Ordering::SeqCst)
}
}
fn retrying_config(max_total_retry_duration: Duration) -> HttpClientConfig {
HttpClientConfig {
request_timeout: Some(Duration::from_secs(5)),
connect_timeout: Some(Duration::from_secs(2)),
read_timeout: Some(Duration::from_secs(2)),
retry: RetryConfig {
max_retries: 50,
initial_backoff: Duration::from_millis(10),
max_retry_interval: Duration::from_millis(50),
},
max_total_retry_duration,
..HttpClientConfig::default()
}
}
#[tokio::test]
async fn post_500_receives_exactly_one_upstream_hit() {
let responder = ScriptedResponder::spawn(vec!["HTTP/1.1 500 Internal Server Error"]).await;
let http =
SharedHttpClient::new(retrying_config(Duration::from_secs(10))).expect("client builds");
let response = http
.client()
.post(format!("http://{}/mutate", responder.addr))
.send()
.await
.expect("500 is a delivered response, not a transport error");
assert_eq!(response.status(), 500);
assert_eq!(
responder.hits(),
1,
"the method gate must bypass the retry middleware for POST: a non-idempotent request is never re-sent"
);
}
#[tokio::test]
async fn get_500_twice_then_success_is_exactly_three_hits() {
let responder = ScriptedResponder::spawn(vec![
"HTTP/1.1 500 Internal Server Error",
"HTTP/1.1 503 Service Unavailable",
"HTTP/1.1 200 OK",
])
.await;
let http =
SharedHttpClient::new(retrying_config(Duration::from_secs(10))).expect("client builds");
let response = http
.client()
.get(format!("http://{}/flaky", responder.addr))
.send()
.await
.expect("retry-to-success delivers the final response");
assert_eq!(response.status(), 200);
assert_eq!(
responder.hits(),
3,
"two failed attempts retried, third attempt succeeded"
);
}
#[tokio::test]
async fn budget_exhaustion_stops_retries_despite_a_generous_attempt_cap() {
let responder = ScriptedResponder::spawn(vec!["HTTP/1.1 500 Internal Server Error"]).await;
let http =
SharedHttpClient::new(retrying_config(Duration::from_millis(400))).expect("client builds");
let started = std::time::Instant::now();
let response = http
.client()
.get(format!("http://{}/stuck", responder.addr))
.send()
.await
.expect("500 is a delivered response, not a transport error");
assert_eq!(response.status(), 500);
let hits = responder.hits();
assert_eq!(
response.status(),
500,
"the surfaced status is the final upstream 500"
);
assert!(
hits >= 2,
"at least one retry ran before the budget closed, hits: {hits}"
);
assert!(
hits < 50,
"budget exhaustion must stop retries long before the 50-attempt cap, hits: {hits}"
);
assert!(
started.elapsed() < Duration::from_secs(3),
"wall time is bounded by max_total_retry_duration + one attempt, took {:?} over {hits} hits",
started.elapsed()
);
}