//! Retry-policy wire coverage (review-002 CLI-03, FWD-04): counting //! responders against the real `SharedHttpClient` middleware stack. //! //! Pinned here: //! //! - the method gate — a POST gets exactly one upstream hit even when //! the upstream answers 500 (non-idempotent requests never re-send); //! - the idempotent retry path — a GET that 500s twice then succeeds //! results in exactly three upstream hits; //! - the wall-clock budget — with a generous attempt cap but a small //! `max_total_retry_duration`, an always-500 upstream stops the //! retry loop on budget exhaustion, bounding both the wall time and //! the hit count. use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use std::time::Duration; use alkhttp::client::{HttpClientConfig, RetryConfig, SharedHttpClient}; use std::sync::Mutex; /// A raw-TCP HTTP/1.1 responder that counts accepted connections and /// answers each request from a per-attempt script: every entry is one /// response head; the last entry repeats when the script runs out. struct ScriptedResponder { addr: std::net::SocketAddr, hits: Arc, } 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() ); }