leyline-http 0.1.0

HTTP client that mimics browsers on the wire
Documentation
use std::time::{Duration, Instant};

use leyline::{Error, ProtocolPolicy, ProxyConfig, Session};
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;

#[tokio::test]
async fn response_header_timeout_fires_when_upstream_goes_silent() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut sock, _) = listener.accept().await.unwrap();
        let mut buf = [0u8; 1024];
        let _ = sock.read(&mut buf).await;
        tokio::time::sleep(Duration::from_secs(30)).await;
        drop(sock);
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .proxy(ProxyConfig::new().env(false))
        .timeout(
            leyline::TimeoutConfig::default()
                .total(Duration::from_secs(10))
                .response_header(Duration::from_millis(300)),
        )
        .build()
        .expect("session builds");

    let start = Instant::now();
    let result = session.get(format!("http://{addr}/")).await;
    let elapsed = start.elapsed();

    assert!(
        result.as_ref().is_err_and(Error::is_timeout),
        "expected Error::new(Kind::Timeout), got {result:?}"
    );
    assert!(
        elapsed < Duration::from_secs(2),
        "TTFB cap should fire near 300ms, took {elapsed:?} — likely fell through to `total`"
    );

    server.abort();
}

#[tokio::test]
async fn no_response_header_timeout_means_request_survives_past_the_ttfb_window() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut sock, _) = listener.accept().await.unwrap();
        let mut buf = [0u8; 1024];
        let _ = sock.read(&mut buf).await;
        tokio::time::sleep(Duration::from_millis(600)).await;
        use tokio::io::AsyncWriteExt;
        let _ = sock
            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
            .await;
        let _ = sock.flush().await;
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .proxy(ProxyConfig::new().env(false))
        .timeout(Duration::from_secs(10))
        .build()
        .expect("session builds");

    let resp = session
        .get(format!("http://{addr}/"))
        .await
        .expect("request without a TTFB cap survives the 600ms stall");
    assert_eq!(resp.status(), 200);

    let _ = server.await;
}

#[tokio::test]
async fn session_recovers_after_ttfb_timeout_no_pool_wedge() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let mut n = 0u32;
        loop {
            let (mut sock, _) = listener.accept().await.unwrap();
            n += 1;
            let stall = n == 1;
            tokio::spawn(async move {
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf).await;
                if stall {
                    tokio::time::sleep(Duration::from_secs(5)).await;
                } else {
                    use tokio::io::AsyncWriteExt;
                    let _ = sock
                        .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
                        .await;
                    let _ = sock.flush().await;
                }
            });
        }
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .proxy(ProxyConfig::new().env(false))
        .timeout(
            leyline::TimeoutConfig::default()
                .total(Duration::from_secs(10))
                .response_header(Duration::from_millis(250)),
        )
        .build()
        .expect("session builds");

    let first = session.get(format!("http://{addr}/")).await;
    assert!(
        first.as_ref().is_err_and(Error::is_timeout),
        "first request should TTFB-timeout, got {first:?}"
    );

    let second = session
        .get(format!("http://{addr}/"))
        .await
        .expect("session must recover and succeed after a TTFB timeout");
    assert_eq!(second.status(), 200);

    server.abort();
}

#[tokio::test]
async fn total_backstop_bounds_silence_when_ttfb_unset() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut sock, _) = listener.accept().await.unwrap();
        let mut buf = [0u8; 1024];
        let _ = sock.read(&mut buf).await;
        tokio::time::sleep(Duration::from_secs(30)).await;
        drop(sock);
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .proxy(ProxyConfig::new().env(false))
        .timeout(Duration::from_millis(400))
        .build()
        .expect("session builds");

    let start = Instant::now();
    let result = session.get(format!("http://{addr}/")).await;
    let elapsed = start.elapsed();

    assert!(
        result.as_ref().is_err_and(Error::is_timeout),
        "expected total Timeout, got {result:?}"
    );
    assert!(
        elapsed >= Duration::from_millis(350) && elapsed < Duration::from_secs(2),
        "total backstop should fire near 400ms, took {elapsed:?}"
    );

    server.abort();
}