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
#![expect(
    clippy::unwrap_used,
    reason = "test/example harness: unwrap doubles as the assertion - a failed helper panics with the test location"
)]
use std::sync::{Arc, Mutex};
use std::time::Duration;

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

async fn gateway_proxy() -> (String, Arc<Mutex<Vec<String>>>) {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://{}", listener.local_addr().unwrap());
    let seen = Arc::new(Mutex::new(Vec::new()));
    let log = Arc::clone(&seen);
    tokio::spawn(async move {
        while let Ok((mut socket, _)) = listener.accept().await {
            let mut head = Vec::new();
            let mut buf = [0u8; 1024];
            while !head.windows(4).any(|w| w == b"\r\n\r\n") {
                match socket.read(&mut buf).await {
                    Ok(0) | Err(_) => break,
                    Ok(n) => head.extend_from_slice(&buf[..n]),
                }
            }
            let line = String::from_utf8_lossy(&head)
                .lines()
                .next()
                .unwrap_or_default()
                .to_owned();
            log.lock().unwrap().push(line);
            drop(
                socket
                    .write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
                    .await,
            );
        }
    });
    (url, seen)
}

#[cfg(feature = "websocket")]
#[tokio::test]
async fn a_websocket_uses_an_https_proxy_rule() {
    let (proxy, seen) = gateway_proxy().await;
    let session = Session::builder()
        .proxy(ProxyConfig::new().env(false).rule(ProxyRule::https(proxy)))
        .build()
        .unwrap();
    drop(
        session
            .websocket("wss://127.0.0.1:9/socket")
            .connect()
            .await,
    );
    let lines = seen.lock().unwrap().clone();
    assert!(
        lines.iter().any(|line| line.starts_with("CONNECT ")),
        "{lines:?}"
    );
}

#[tokio::test]
async fn a_proxy_answering_502_is_retried() {
    let (proxy, seen) = gateway_proxy().await;
    let session = Session::builder()
        .proxy(ProxyConfig::new().env(false).rule(ProxyRule::all(proxy)))
        .retry(
            RetryPolicy::transient()
                .max_retries(2)
                .initial_backoff(Duration::from_millis(1)),
        )
        .build()
        .unwrap();
    let err = session.get("https://127.0.0.1:9/").await.unwrap_err();
    let connects = seen.lock().unwrap().len();
    assert!(connects >= 2, "{connects} CONNECT attempts, {err:?}");
}