leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use std::time::{Duration, Instant};

use leyline::{ProtocolPolicy, ProxyConfig, RetryPolicy, Session, TimeoutConfig};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

#[tokio::test]
async fn connect_timeout_does_not_bound_the_response_phase() {
    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];
        drop(sock.read(&mut buf).await);
        tokio::time::sleep(Duration::from_millis(600)).await;
        drop(
            sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
                .await,
        );
        drop(sock.flush().await);
    });

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

    let start = Instant::now();
    let resp = session
        .get(format!("http://{addr}/"))
        .await
        .expect("a 300ms connect timeout must not clip a response served at 600ms");
    let elapsed = start.elapsed();

    assert_eq!(resp.status(), 200);
    assert!(
        elapsed >= Duration::from_millis(550),
        "returned in {elapsed:?}, before the server's 600ms think time — the response was short-circuited, not served"
    );

    drop(server.await);
}

#[tokio::test]
async fn connect_timeout_bounds_an_unreachable_endpoint() {
    let session = Session::builder()
        .proxy(ProxyConfig::new().env(false))
        .timeout(
            TimeoutConfig::new()
                .total(Duration::from_secs(10))
                .connect(Duration::from_millis(300)),
        )
        .build()
        .expect("session builds");

    let start = Instant::now();
    let result = session
        .request(http::Method::GET, "https://192.0.2.1:443/")
        .retry(RetryPolicy::none())
        .send()
        .await;
    let elapsed = start.elapsed();

    assert!(
        result.is_err(),
        "unreachable endpoint must error, got {result:?}"
    );
    assert!(
        elapsed < Duration::from_secs(3),
        "connect bail took {elapsed:?} — connect_timeout did not bound it (fell through toward the 10s total)"
    );
}

#[tokio::test]
async fn connect_timeout_is_one_window_per_request_on_http_and_https() {
    let session = Session::builder()
        .proxy(ProxyConfig::new().env(false))
        .timeout(TimeoutConfig::new().connect(Duration::from_millis(300)))
        .build()
        .expect("session builds");

    for url in ["http://192.0.2.1:80/", "https://192.0.2.1:443/"] {
        let start = Instant::now();
        let result = session
            .request(http::Method::GET, url)
            .retry(RetryPolicy::none())
            .send()
            .await;
        let elapsed = start.elapsed();

        assert!(result.is_err(), "{url} must error, got {result:?}");
        assert!(
            elapsed < Duration::from_millis(550),
            "{url} took {elapsed:?}; the 300ms connect timeout must bound the whole request"
        );
    }
}