alkhttp 0.5.0

HTTP interface for the alk stack: serves HTTP/1.1 + HTTP/2 on standard ALPNs (with WebSocket upgrade carrying the channels protocol) and hosts the HTTP-backed call-protocol adapters
Documentation
//! 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<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()
    );
}