#![cfg(feature = "tokio")]
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Full;
use hyper::Response;
use aioduct::HttpEngineSend;
use aioduct::observer::{ConnectionEvent, RequestEvent, RequestObserver, RequestPhase};
use aioduct::runtime::TokioRuntime;
use aioduct::runtime::tokio_rt::TcpConnector;
use aioduct_test_server::h1::{echo, h1_server, h1_server_with};
use aioduct_test_server::h2::h2_server_with;
#[tokio::test]
async fn https_only_rejects_http_url() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.https_only(true)
.build()
.unwrap();
let result = client.get(&format!("http://{addr}/")).unwrap().send().await;
assert!(result.is_err(), "https_only should reject http:// URLs");
}
#[tokio::test]
async fn cookie_jar_stores_and_sends() {
let (addr, _counter) = h1_server_with(|req| async move {
let cookie = req
.headers()
.get("cookie")
.map(|v| v.to_str().unwrap_or("").to_owned())
.unwrap_or_default();
if req.uri().path() == "/set" {
Ok::<_, Infallible>(
Response::builder()
.header("set-cookie", "token=xyz789; Path=/")
.body(Full::new(Bytes::from("cookie set")))
.unwrap(),
)
} else {
Ok(Response::new(Full::new(Bytes::from(format!(
"cookies={cookie}"
)))))
}
})
.await;
let jar = aioduct::CookieJar::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cookie_jar(jar)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/set"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "cookie set");
let resp = client
.get(&format!("http://{addr}/check"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("token=xyz789"),
"cookie should be present, got: {body}"
);
}
#[tokio::test]
async fn middleware_injects_header() {
let (addr, _counter) = h1_server_with(|req| async move {
let val = req
.headers()
.get("x-middleware")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(val))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(
|req: &mut http::Request<aioduct::body::RequestBodySend>, _uri: &http::Uri| {
req.headers_mut().insert(
http::header::HeaderName::from_static("x-middleware"),
http::header::HeaderValue::from_static("dispatch-test"),
);
},
)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "dispatch-test");
}
#[derive(Default, Clone)]
struct TestObserver {
phases: Arc<Mutex<Vec<String>>>,
conn_events: Arc<Mutex<Vec<String>>>,
}
impl RequestObserver for TestObserver {
fn on_event(&self, event: &RequestEvent) {
let phase_name = match &event.phase {
RequestPhase::Started => "Started".to_string(),
RequestPhase::PoolCheckoutComplete { .. } => "PoolCheckoutComplete".to_string(),
RequestPhase::DnsResolved { .. } => "DnsResolved".to_string(),
RequestPhase::TcpConnected { .. } => "TcpConnected".to_string(),
RequestPhase::TlsHandshakeComplete { .. } => "TlsHandshakeComplete".to_string(),
RequestPhase::RequestSent { .. } => "RequestSent".to_string(),
RequestPhase::ResponseStarted { .. } => "ResponseStarted".to_string(),
RequestPhase::ResponseComplete { .. } => "ResponseComplete".to_string(),
RequestPhase::Failed { .. } => "Failed".to_string(),
RequestPhase::BytesTransferred { .. } => "BytesTransferred".to_string(),
RequestPhase::TransferComplete { .. } => "TransferComplete".to_string(),
RequestPhase::TransferAborted { .. } => "TransferAborted".to_string(),
RequestPhase::Redirected { .. } => "Redirected".to_string(),
RequestPhase::Retrying { .. } => "Retrying".to_string(),
RequestPhase::TrailersReceived { .. } => "TrailersReceived".to_string(),
};
self.phases.lock().unwrap().push(phase_name);
}
fn on_connection_event(&self, event: &ConnectionEvent) {
let name = format!("{:?}", event.phase);
self.conn_events.lock().unwrap().push(name);
}
}
#[tokio::test]
async fn observer_receives_lifecycle_events() {
let (addr, _counter) = h1_server().await;
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.request_observer(obs.clone())
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
let phases = obs.phases.lock().unwrap();
assert!(
phases.contains(&"Started".to_string()),
"phases: {phases:?}"
);
assert!(
phases.contains(&"DnsResolved".to_string()),
"phases: {phases:?}"
);
assert!(
phases.contains(&"TcpConnected".to_string()),
"phases: {phases:?}"
);
assert!(
phases.contains(&"RequestSent".to_string()),
"phases: {phases:?}"
);
assert!(
phases.contains(&"ResponseComplete".to_string()),
"phases: {phases:?}"
);
}
#[tokio::test]
async fn read_timeout_fires_on_stalled_body() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\npartial")
.await
.unwrap();
stream.flush().await.unwrap();
tokio::time::sleep(Duration::from_secs(60)).await;
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.read_timeout(Duration::from_millis(50))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body_result = resp.text().await;
assert!(
body_result.is_err(),
"read_timeout should fire when body stalls"
);
}
#[tokio::test]
async fn bandwidth_limiter_allows_request() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_download_speed(1024 * 1024)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn rate_limiter_allows_request() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_requests_per_sec(100)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn cache_serves_second_request_from_store() {
let hit_count = Arc::new(AtomicU32::new(0));
let hit_count_clone = hit_count.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let count = hit_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=3600")
.body(Full::new(Bytes::from("cached response")))
.unwrap(),
)
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached response");
assert_eq!(hit_count.load(Ordering::SeqCst), 1);
let resp = client
.get(&format!("http://{addr}/resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached response");
assert_eq!(
hit_count.load(Ordering::SeqCst),
1,
"second request should be served from cache"
);
}
#[tokio::test]
async fn hsts_store_basic_request_works() {
let (addr, _counter) = h1_server().await;
let hsts = aioduct::HstsStore::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.hsts(hsts)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn no_decompression_omits_accept_encoding() {
let (addr, _counter) = h1_server_with(|req| async move {
let has_accept_encoding = req.headers().contains_key("accept-encoding");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"accept-encoding={}",
has_accept_encoding
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.no_decompression()
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert_eq!(
body, "accept-encoding=false",
"no_decompression should not send accept-encoding header"
);
}
#[tokio::test]
async fn resolve_override_routes_to_target() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.resolve("custom.local", addr)
.build()
.unwrap();
let resp = client
.get(&format!("http://custom.local:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn too_many_redirects_returns_error() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("Location", "/loop")
.body(Full::new(Bytes::new()))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_redirects(3)
.build()
.unwrap();
let result = client
.get(&format!("http://{addr}/start"))
.unwrap()
.send()
.await;
assert!(result.is_err(), "should error on too many redirects");
let err = result.unwrap_err();
assert!(err.is_redirect(), "expected redirect error, got: {err:?}");
}
#[tokio::test]
async fn redirect_303_changes_post_to_get() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
if path == "/submit" {
Ok::<_, Infallible>(
Response::builder()
.status(303)
.header("Location", "/result")
.body(Full::new(Bytes::new()))
.unwrap(),
)
} else {
let method = req.method().to_string();
Ok(Response::new(Full::new(Bytes::from(format!(
"method={method}"
)))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.post(&format!("http://{addr}/submit"))
.unwrap()
.body("data")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.text().await.unwrap();
assert!(
body.contains("method=GET"),
"303 should change POST to GET, got: {body}"
);
}
#[tokio::test]
async fn connect_timeout_fires_on_unreachable() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.connect_timeout(Duration::from_millis(1))
.build()
.unwrap();
let result = client.get("http://192.0.2.1:1/").unwrap().send().await;
assert!(result.is_err(), "connect_timeout should fire");
let err = result.unwrap_err();
assert!(
err.is_timeout() || err.is_connect(),
"expected timeout or connect error, got: {err:?}"
);
}
#[tokio::test]
async fn tcp_keepalive_basic_request_works() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tcp_keepalive(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn error_for_status_on_500() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.status(500)
.body(Full::new(Bytes::from("server error")))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 500);
let result = resp.error_for_status();
assert!(result.is_err(), "error_for_status should error on 500");
let err = result.unwrap_err();
assert!(err.is_status(), "expected status error, got: {err:?}");
}
#[cfg(feature = "json")]
#[tokio::test]
async fn json_post_sets_content_type() {
let (addr, _counter) = h1_server_with(echo).await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.post(&format!("http://{addr}/"))
.unwrap()
.json(&serde_json::json!({"key": "value"}))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("content-type: application/json"),
"expected application/json content-type, got: {body}"
);
}
#[tokio::test]
async fn form_post_sets_content_type() {
let (addr, _counter) = h1_server_with(echo).await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.post(&format!("http://{addr}/"))
.unwrap()
.form(&[("name", "test"), ("value", "123")])
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("content-type: application/x-www-form-urlencoded"),
"expected form content-type, got: {body}"
);
}
#[tokio::test]
async fn h2c_prior_knowledge_works() {
let (addr, _counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2 ok");
}
#[tokio::test]
async fn no_connection_reuse_opens_new_connections() {
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (addr, counter) = h1_server_with(move |_req| {
let count = request_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("ok"))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.no_connection_reuse()
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await;
assert_eq!(request_count.load(Ordering::SeqCst), 2);
assert!(
counter.connections() >= 2,
"expected at least 2 connections, got {}",
counter.connections()
);
}
#[tokio::test]
async fn h2_pool_hit_reuses_connection() {
let (addr, counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 reuse"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/first"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let resp = client
.get(&format!("http://{addr}/second"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2 reuse");
assert_eq!(
counter.connections(),
1,
"H2 should reuse the connection (pool hit), got {} connections",
counter.connections()
);
assert_eq!(counter.requests(), 2);
}
#[tokio::test]
async fn h2_concurrent_requests_multiplex_single_connection() {
let (addr, counter) = h2_server_with(|_req| async {
tokio::time::sleep(Duration::from_millis(10)).await;
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 multiplex"))))
})
.await;
let client = Arc::new(
HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap(),
);
let mut handles = Vec::new();
for i in 0..5 {
let client = client.clone();
let url = format!("http://{addr}/req{i}");
handles.push(tokio::spawn(async move {
client.get(&url).unwrap().h2c_prior_knowledge().send().await
}));
}
for handle in handles {
let resp = handle.await.unwrap().unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2 multiplex");
}
assert!(
counter.connections() <= 2,
"H2 multiplex should use minimal connections, got {}",
counter.connections()
);
assert_eq!(counter.requests(), 5);
}
#[tokio::test]
async fn stale_connection_retry_on_rst() {
let (addr, counter) = aioduct_test_server::stale::h1_rst_on_reuse().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
assert!(
counter.connections() >= 2,
"expected at least 2 connections for stale retry, got {}",
counter.connections()
);
}
#[tokio::test]
async fn stale_connection_retry_on_fin() {
let (addr, counter) = aioduct_test_server::stale::h1_fin_on_reuse().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
assert!(
counter.connections() >= 2,
"expected at least 2 connections for stale retry on FIN, got {}",
counter.connections()
);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn tls_connection_exercises_tls_path() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, _counter) = aioduct_test_server::tls::tls_h2_server().await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.version(),
http::Version::HTTP_2,
"Should negotiate h2 via ALPN"
);
assert!(
resp.tls_info().is_some(),
"TLS info should be present on the response"
);
assert_eq!(resp.text().await.unwrap(), "hello tls");
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn tls_h1_connection_path() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, _counter) = aioduct_test_server::tls::tls_h1_server(&[b"http/1.1"]).await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.version(),
http::Version::HTTP_11,
"Should use HTTP/1.1 when server only offers http/1.1 ALPN"
);
assert_eq!(resp.text().await.unwrap(), "hello tls");
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn tls_h2_multiplex_checkin_path() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, counter) = aioduct_test_server::tls::tls_h2_server().await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client = Arc::new(
HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
);
let mut handles = Vec::new();
for i in 0..4 {
let client = client.clone();
let url = format!("https://localhost:{}/req{i}", addr.port());
handles.push(tokio::spawn(async move {
client.get(&url).unwrap().send().await
}));
}
for handle in handles {
let resp = handle.await.unwrap().unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello tls");
}
assert!(
counter.connections() <= 2,
"TLS H2 multiplex should use minimal connections, got {}",
counter.connections()
);
assert_eq!(counter.requests(), 4);
}
#[tokio::test]
async fn http_proxy_injects_proxy_authorization() {
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let auth_seen = Arc::new(AtomicBool::new(false));
let auth_seen_clone = auth_seen.clone();
let (target_addr, _counter) = aioduct_test_server::h1::h1_server().await;
let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let auth = auth_seen_clone;
tokio::spawn(async move {
loop {
let (mut client, _) = proxy_listener.accept().await.unwrap();
let auth = auth.clone();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let n = client.read(&mut buf).await.unwrap();
let req_str = String::from_utf8_lossy(&buf[..n]);
if !req_str.starts_with("CONNECT") {
return;
}
if req_str.contains("proxy-authorization:")
|| req_str.contains("Proxy-Authorization:")
{
auth.store(true, Ordering::SeqCst);
}
let target = req_str.split_whitespace().nth(1).unwrap_or("");
let _ = client
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await;
let mut upstream = match tokio::net::TcpStream::connect(target).await {
Ok(s) => s,
Err(_) => return,
};
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
let proxy = aioduct::ProxyConfig::http(&format!("http://{proxy_addr}"))
.unwrap()
.basic_auth("user", "secret");
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(proxy)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
auth_seen.load(Ordering::SeqCst),
"CONNECT request should include Proxy-Authorization header"
);
}
#[tokio::test]
async fn h2_pool_hit_observer_reports_hit() {
let (addr, _counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 observed"))))
})
.await;
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.request_observer(obs.clone())
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/first"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let resp = client
.get(&format!("http://{addr}/second"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2 observed");
let phases = obs.phases.lock().unwrap();
let pool_checkout_count = phases
.iter()
.filter(|p| *p == "PoolCheckoutComplete")
.count();
assert!(
pool_checkout_count >= 2,
"expected at least 2 PoolCheckoutComplete events, got {pool_checkout_count}"
);
}
#[tokio::test]
async fn no_connection_reuse_prevents_pool_checkin_h2() {
let (addr, counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 no reuse"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.no_connection_reuse()
.build()
.unwrap();
for i in 0..3 {
let resp = client
.get(&format!("http://{addr}/req{i}"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
assert_eq!(
counter.connections(),
3,
"no_connection_reuse should open new connection each time, got {}",
counter.connections()
);
}
#[tokio::test]
async fn h1_pool_hit_reuses_connection() {
let (addr, counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h1 reuse"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/first"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let resp = client
.get(&format!("http://{addr}/second"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h1 reuse");
assert_eq!(
counter.connections(),
1,
"H1 should reuse the connection via pool hit, got {} connections",
counter.connections()
);
assert_eq!(counter.requests(), 2);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn tls_no_alpn_falls_to_h1() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, _counter) = aioduct_test_server::tls::tls_h1_server(&[]).await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello tls");
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn tls_h2_sequential_reuses_connection() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, counter) = aioduct_test_server::tls::tls_h2_server().await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let url = format!("https://localhost:{}/", addr.port());
for _ in 0..3 {
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
assert_eq!(
counter.connections(),
1,
"TLS H2 sequential requests should reuse 1 connection, got {}",
counter.connections()
);
assert_eq!(counter.requests(), 3);
}
#[tokio::test]
async fn h2_goaway_triggers_fresh_connection() {
let (addr, counter) = aioduct_test_server::h2::h2_goaway_after(2).await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
for _ in 0..2 {
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
assert!(
counter.connections() >= 2,
"expected at least 2 connections after GOAWAY, got {}",
counter.connections()
);
}
#[tokio::test]
async fn proxy_settings_injects_authorization_on_http() {
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let auth_seen = Arc::new(AtomicBool::new(false));
let auth_seen_clone = auth_seen.clone();
let (target_addr, _counter) = aioduct_test_server::h1::h1_server().await;
let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let auth = auth_seen_clone;
tokio::spawn(async move {
loop {
let (mut client, _) = proxy_listener.accept().await.unwrap();
let auth = auth.clone();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let n = client.read(&mut buf).await.unwrap();
let req_str = String::from_utf8_lossy(&buf[..n]);
if !req_str.starts_with("CONNECT") {
return;
}
if req_str.contains("proxy-authorization:")
|| req_str.contains("Proxy-Authorization:")
{
auth.store(true, Ordering::SeqCst);
}
let target = req_str.split_whitespace().nth(1).unwrap_or("");
let _ = client
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await;
let mut upstream = match tokio::net::TcpStream::connect(target).await {
Ok(s) => s,
Err(_) => return,
};
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
let proxy = aioduct::ProxyConfig::http(&format!("http://{proxy_addr}"))
.unwrap()
.basic_auth("admin", "password123");
let settings = aioduct::ProxySettings::all(proxy);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/api"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
auth_seen.load(Ordering::SeqCst),
"CONNECT request should include Proxy-Authorization header"
);
}
#[tokio::test]
async fn stale_retry_rst_every_n_succeeds() {
let (addr, counter) = aioduct_test_server::stale::h1_rst_every_n(2).await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
for i in 0..2 {
let resp = client
.get(&format!("http://{addr}/req{i}"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
let resp = client
.get(&format!("http://{addr}/req2"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
assert!(
counter.connections() >= 2,
"expected at least 2 connections with RST after 2 requests, got {}",
counter.connections()
);
}
#[tokio::test]
async fn h2_pool_hit_after_concurrent_establishment() {
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (addr, counter) = h2_server_with(move |_req| {
let count = request_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 pool hit"))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/setup"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let client = Arc::new(client);
let mut handles = Vec::new();
for i in 0..3 {
let client = client.clone();
let url = format!("http://{addr}/concurrent{i}");
handles.push(tokio::spawn(async move {
client.get(&url).unwrap().h2c_prior_knowledge().send().await
}));
}
for handle in handles {
let resp = handle.await.unwrap().unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
assert_eq!(
counter.connections(),
1,
"all requests should multiplex on 1 connection, got {}",
counter.connections()
);
assert_eq!(request_count.load(Ordering::SeqCst), 4); }
#[cfg(feature = "rustls")]
#[tokio::test]
async fn hsts_stored_from_https_response() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, _counter) =
aioduct_test_server::tls::tls_server_with(&[b"http/1.1"], |_req| async {
Ok::<_, Infallible>(
Response::builder()
.header(
"strict-transport-security",
"max-age=31536000; includeSubDomains",
)
.body(Full::new(Bytes::from("hsts response")))
.unwrap(),
)
})
.await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let hsts = aioduct::HstsStore::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.hsts(hsts.clone())
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hsts response");
assert!(
hsts.should_upgrade("localhost"),
"HSTS should be stored from HTTPS response with STS header"
);
}
#[tokio::test]
async fn cache_invalidation_on_post() {
let hit_count = Arc::new(AtomicU32::new(0));
let hit_count_clone = hit_count.clone();
let (addr, _counter) = h1_server_with(move |req| {
let count = hit_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
if req.method() == http::Method::POST {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("posted"))))
} else {
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=3600")
.body(Full::new(Bytes::from("cached")))
.unwrap(),
)
}
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let url = format!("http://{addr}/resource");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached");
assert_eq!(hit_count.load(Ordering::SeqCst), 1);
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached");
assert_eq!(
hit_count.load(Ordering::SeqCst),
1,
"second GET should be from cache"
);
let resp = client
.post(&url)
.unwrap()
.body("data")
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "posted");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached");
assert!(
hit_count.load(Ordering::SeqCst) >= 3,
"GET after POST should re-fetch from server, got {} requests",
hit_count.load(Ordering::SeqCst)
);
}
#[tokio::test]
async fn redirect_307_preserves_method_and_body() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
if path == "/submit" {
Ok::<_, Infallible>(
Response::builder()
.status(307)
.header("Location", "/result")
.body(Full::new(Bytes::new()))
.unwrap(),
)
} else {
use http_body_util::BodyExt;
let method = req.method().to_string();
let body = req.collect().await.unwrap().to_bytes();
Ok(Response::new(Full::new(Bytes::from(format!(
"method={method},body={}",
String::from_utf8_lossy(&body)
)))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.post(&format!("http://{addr}/submit"))
.unwrap()
.body("my-data")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.text().await.unwrap();
assert!(
body.contains("method=POST"),
"307 should preserve POST method, got: {body}"
);
assert!(
body.contains("body=my-data"),
"307 should replay the body, got: {body}"
);
}
#[tokio::test]
async fn observer_fires_connection_metrics_on_checkin() {
let (addr, _counter) = h1_server().await;
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.request_observer(obs.clone())
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
let conn_events = obs.conn_events.lock().unwrap();
assert!(
!conn_events.is_empty(),
"observer should receive connection metrics events on checkin, got: {conn_events:?}"
);
assert!(
conn_events.iter().any(|e| e.contains("Metrics")),
"expected Metrics connection event, got: {conn_events:?}"
);
}
#[tokio::test]
async fn observer_fires_connection_metrics_on_h2_multiplex_clone() {
let (addr, _counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 metrics"))))
})
.await;
let obs = TestObserver::default();
let client = Arc::new(
HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.request_observer(obs.clone())
.build()
.unwrap(),
);
let resp = client
.get(&format!("http://{addr}/first"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
let resp = client
.get(&format!("http://{addr}/second"))
.unwrap()
.h2c_prior_knowledge()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
let conn_events = obs.conn_events.lock().unwrap();
assert!(
!conn_events.is_empty(),
"observer should receive connection metrics for H2 multiplex"
);
}
#[tokio::test]
async fn hsts_upgrade_redirects_http_to_https() {
let hsts = aioduct::HstsStore::new();
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::HeaderName::from_static("strict-transport-security"),
http::header::HeaderValue::from_static("max-age=31536000"),
);
hsts.store_from_response("localhost", &headers);
assert!(hsts.should_upgrade("localhost"));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.hsts(hsts)
.timeout(Duration::from_millis(500))
.build()
.unwrap();
let result = client.get("http://localhost:9999/").unwrap().send().await;
assert!(
result.is_err(),
"HSTS upgrade should cause the request to fail without TLS"
);
}
#[tokio::test]
async fn default_headers_applied_to_request() {
let (addr, _counter) = h1_server_with(|req| async move {
let custom = req
.headers()
.get("x-custom-default")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "missing".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"custom={custom}"
)))))
})
.await;
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::HeaderName::from_static("x-custom-default"),
http::header::HeaderValue::from_static("default-value"),
);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.default_headers(headers)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("custom=default-value"),
"default headers should be applied, got: {body}"
);
}
#[tokio::test]
async fn default_headers_do_not_override_explicit() {
let (addr, _counter) = h1_server_with(|req| async move {
let val = req
.headers()
.get("x-custom-default")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "missing".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(val))))
})
.await;
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::HeaderName::from_static("x-custom-default"),
http::header::HeaderValue::from_static("default-value"),
);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.default_headers(headers)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.header_str("x-custom-default", "explicit-value")
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert_eq!(
body, "explicit-value",
"explicit header should override default"
);
}
#[tokio::test]
async fn redirect_308_streaming_body_errors() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.status(308)
.header("Location", "/target")
.body(Full::new(Bytes::new()))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
use http_body_util::BodyExt as _;
let chunks: Vec<Result<hyper::body::Frame<Bytes>, aioduct::Error>> =
vec![Ok(hyper::body::Frame::data(Bytes::from("stream")))];
let stream = futures_util::stream::iter(chunks);
let streaming_body: aioduct::body::RequestBodySend =
http_body_util::StreamBody::new(stream).boxed_unsync();
let result = client
.post(&format!("http://{addr}/submit"))
.unwrap()
.body_stream(streaming_body)
.send()
.await;
assert!(
result.is_err(),
"308 redirect with streaming body should error"
);
}
#[tokio::test]
async fn redirect_policy_none_returns_redirect_directly() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
if path == "/start" {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("Location", "/target")
.body(Full::new(Bytes::new()))
.unwrap(),
)
} else {
Ok(Response::new(Full::new(Bytes::from("reached target"))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.redirect_policy(aioduct::RedirectPolicy::none())
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/start"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 302);
assert!(
resp.headers().contains_key("location"),
"redirect response should contain Location header"
);
}
#[tokio::test]
async fn referer_header_added_on_redirect() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
if path == "/source" {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("Location", "/dest")
.body(Full::new(Bytes::new()))
.unwrap(),
)
} else {
let referer = req
.headers()
.get("referer")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
Ok(Response::new(Full::new(Bytes::from(format!(
"referer={referer}"
)))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.referer(true)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/source"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains(&format!("http://{addr}/source")),
"referer should contain the source URL, got: {body}"
);
}
#[tokio::test]
async fn cache_304_revalidation_via_execute_send() {
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let (addr, _counter) = h1_server_with(move |req| {
let attempt = attempt_clone.clone();
async move {
let n = attempt.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=0, must-revalidate")
.header("etag", "\"revalidate-v1\"")
.body(Full::new(Bytes::from("original body")))
.unwrap(),
)
} else {
let inm = req
.headers()
.get("if-none-match")
.map(|v| v.to_str().unwrap().to_owned())
.unwrap_or_default();
if inm.contains("\"revalidate-v1\"") {
Ok(Response::builder()
.status(304)
.header("etag", "\"revalidate-v1\"")
.body(Full::new(Bytes::new()))
.unwrap())
} else {
Ok(Response::new(Full::new(Bytes::from("new body"))))
}
}
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/revalidate-send"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "original body");
let resp = client
.get(&format!("http://{addr}/revalidate-send"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "original body");
assert_eq!(
attempt.load(Ordering::SeqCst),
2,
"server should be hit twice"
);
}
#[tokio::test]
async fn cache_stale_if_error_on_5xx_via_execute_send() {
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let attempt = attempt_clone.clone();
async move {
let n = attempt.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=0, stale-if-error=3600")
.header("etag", "\"sie-v1\"")
.body(Full::new(Bytes::from("stale ok")))
.unwrap(),
)
} else {
Ok(Response::builder()
.status(503)
.body(Full::new(Bytes::from("service unavailable")))
.unwrap())
}
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/sie-send"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "stale ok");
let resp = client
.get(&format!("http://{addr}/sie-send"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "stale ok");
}
#[tokio::test]
async fn digest_auth_retry_via_execute_send() {
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let (addr, _counter) = h1_server_with(move |req| {
let attempt = attempt_clone.clone();
async move {
let n = attempt.fetch_add(1, Ordering::SeqCst);
let has_auth = req.headers().contains_key("authorization");
if n == 0 && !has_auth {
Ok::<_, Infallible>(
Response::builder()
.status(401)
.header(
"www-authenticate",
"Digest realm=\"test\", nonce=\"abc123\", qop=\"auth\"",
)
.body(Full::new(Bytes::from("Unauthorized")))
.unwrap(),
)
} else {
let auth_header = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
Ok(Response::new(Full::new(Bytes::from(format!(
"authed={auth_header}"
)))))
}
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.digest_auth("testuser", "testpass")
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/protected"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.text().await.unwrap();
assert!(
body.contains("Digest"),
"digest auth should produce Digest authorization header, got: {body}"
);
assert!(
body.contains("testuser"),
"digest auth should include username, got: {body}"
);
}
#[tokio::test]
async fn cookie_jar_stores_and_sends_on_next_request() {
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let (addr, _counter) = h1_server_with(move |req| {
let attempt = attempt_clone.clone();
async move {
let n = attempt.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok::<_, Infallible>(
Response::builder()
.header("set-cookie", "session=abc123; Path=/")
.body(Full::new(Bytes::from("set")))
.unwrap(),
)
} else {
let cookie = req
.headers()
.get("cookie")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
Ok(Response::new(Full::new(Bytes::from(format!(
"cookie={cookie}"
)))))
}
}
})
.await;
let jar = aioduct::CookieJar::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cookie_jar(jar)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/set"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "set");
let resp = client
.get(&format!("http://{addr}/check"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("session=abc123"),
"cookie should be sent, got: {body}"
);
}
#[tokio::test]
async fn host_header_auto_inserted() {
let (addr, _counter) = h1_server_with(|req| async move {
let host = req
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "missing".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"host={host}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains(&addr.to_string()),
"host header should contain authority, got: {body}"
);
}
#[tokio::test]
async fn observer_receives_stale_retry_event() {
let (addr, counter) = aioduct_test_server::stale::h1_rst_on_reuse().await;
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.request_observer(obs.clone())
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
let phases = obs.phases.lock().unwrap();
assert!(
phases.iter().any(|p| p.contains("PoolCheckoutComplete")),
"expected PoolCheckoutComplete phase, got: {phases:?}"
);
assert!(
counter.connections() >= 2,
"should have opened at least 2 connections"
);
}
#[tokio::test]
async fn middleware_applies_on_fresh_connection() {
let (addr, _counter) = h1_server_with(|req| async move {
let custom = req
.headers()
.get("x-fresh-middleware")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "missing".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"middleware={custom}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(
|req: &mut http::Request<aioduct::body::RequestBodySend>, _uri: &http::Uri| {
req.headers_mut().insert(
http::header::HeaderName::from_static("x-fresh-middleware"),
http::header::HeaderValue::from_static("fresh-path"),
);
},
)
.no_connection_reuse()
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("middleware=fresh-path"),
"middleware should be applied on fresh connection path, got: {body}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_connection_path() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = std::env::temp_dir().join("aioduct_dispatch_test");
let _ = std::fs::create_dir_all(&dir);
let sock_path = dir.join("dispatch_test.sock");
let _ = std::fs::remove_file(&sock_path);
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => break,
};
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nunix socket";
let _ = stream.write_all(response).await;
let _ = stream.flush().await;
});
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.unix_socket(&sock_path)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get("http://localhost/unix-test")
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "unix socket");
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_with_connect_timeout() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = std::env::temp_dir().join("aioduct_dispatch_test");
let _ = std::fs::create_dir_all(&dir);
let sock_path = dir.join("dispatch_timeout.sock");
let _ = std::fs::remove_file(&sock_path);
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => break,
};
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 14\r\nConnection: close\r\n\r\nunix w/timeout";
let _ = stream.write_all(response).await;
let _ = stream.flush().await;
});
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.unix_socket(&sock_path)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get("http://localhost/timeout-test")
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "unix w/timeout");
}
#[tokio::test]
async fn adaptive_h2c_probe_succeeds_on_h2_server() {
let (addr, counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2c adaptive ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/adaptive-test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2c adaptive ok");
let incoming2 = http::Request::builder()
.method(http::Method::GET)
.uri("/adaptive-test2")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp2 = client
.forward(incoming2)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp2.status(), http::StatusCode::OK);
assert_eq!(resp2.text().await.unwrap(), "h2c adaptive ok");
assert!(
counter.connections() <= 2,
"cached h2c probe should reuse connection, got {} connections",
counter.connections()
);
}
#[tokio::test]
async fn adaptive_h2c_probe_falls_back_to_h1() {
let (addr, counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h1 fallback ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/fallback-test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h1 fallback ok");
let incoming2 = http::Request::builder()
.method(http::Method::GET)
.uri("/fallback-test2")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp2 = client
.forward(incoming2)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp2.status(), http::StatusCode::OK);
assert_eq!(resp2.text().await.unwrap(), "h1 fallback ok");
assert!(
counter.connections() >= 2,
"adaptive h2c probe + fallback should use at least 2 connections, got {}",
counter.connections()
);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn connection_coalescing_reuses_h2_tls_connection() {
use std::sync::atomic::AtomicU32;
aioduct_test_server::tls::install_crypto_provider();
let cert =
aioduct_test_server::tls::generate_self_signed(&["coalesce-a.local", "coalesce-b.local"]);
let cert_der = cert.cert_der.clone();
let counter = aioduct_test_server::ConnectionCounter::new();
let counter2 = counter.clone();
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let config = {
let mut cfg = rustls::ServerConfig::builder_with_provider(
aioduct_test_server::tls::crypto_provider(),
)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert.cert_der.clone()], cert.key_der.clone_key())
.unwrap();
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
std::sync::Arc::new(cfg)
};
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => continue,
};
counter2.inc_connections();
let acceptor = acceptor.clone();
let req_count = request_count_clone.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(stream).await {
Ok(s) => s,
Err(_) => return,
};
let io = aioduct_test_server::TokioIo::new(tls_stream);
let _ = hyper::server::conn::http2::Builder::new(aioduct_test_server::TokioExec)
.serve_connection(
io,
hyper::service::service_fn(move |_req| {
req_count.fetch_add(1, Ordering::SeqCst);
async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(
"coalesced",
))))
}
}),
)
.await;
});
}
});
let mut root_store = rustls::RootCertStore::empty();
root_store.add(cert_der.clone()).unwrap();
let mut client_config =
rustls::ClientConfig::builder_with_provider(aioduct_test_server::tls::crypto_provider())
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(root_store)
.with_no_client_auth();
client_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let connector = aioduct::tls::RustlsConnector::new(std::sync::Arc::new(client_config));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.connection_coalescing(true)
.resolve("coalesce-a.local", addr)
.resolve("coalesce-b.local", addr)
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://coalesce-a.local:{}/first", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.version(), http::Version::HTTP_2);
let _ = resp.text().await.unwrap();
let resp = client
.get(&format!("https://coalesce-b.local:{}/second", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.version(), http::Version::HTTP_2);
assert_eq!(resp.text().await.unwrap(), "coalesced");
assert_eq!(
counter.connections(),
1,
"connection coalescing should reuse single TLS H2 connection, got {}",
counter.connections()
);
assert_eq!(request_count.load(Ordering::SeqCst), 2);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn connection_coalescing_disabled_opens_separate() {
aioduct_test_server::tls::install_crypto_provider();
let cert =
aioduct_test_server::tls::generate_self_signed(&["no-coal-a.local", "no-coal-b.local"]);
let cert_der = cert.cert_der.clone();
let counter = aioduct_test_server::ConnectionCounter::new();
let counter2 = counter.clone();
let config = {
let mut cfg = rustls::ServerConfig::builder_with_provider(
aioduct_test_server::tls::crypto_provider(),
)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert.cert_der.clone()], cert.key_der.clone_key())
.unwrap();
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
std::sync::Arc::new(cfg)
};
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => continue,
};
counter2.inc_connections();
let acceptor = acceptor.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(stream).await {
Ok(s) => s,
Err(_) => return,
};
let io = aioduct_test_server::TokioIo::new(tls_stream);
let _ = hyper::server::conn::http2::Builder::new(aioduct_test_server::TokioExec)
.serve_connection(
io,
hyper::service::service_fn(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("separate"))))
}),
)
.await;
});
}
});
let mut root_store = rustls::RootCertStore::empty();
root_store.add(cert_der.clone()).unwrap();
let mut client_config =
rustls::ClientConfig::builder_with_provider(aioduct_test_server::tls::crypto_provider())
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(root_store)
.with_no_client_auth();
client_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let connector = aioduct::tls::RustlsConnector::new(std::sync::Arc::new(client_config));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.connection_coalescing(false)
.resolve("no-coal-a.local", addr)
.resolve("no-coal-b.local", addr)
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://no-coal-a.local:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let resp = client
.get(&format!("https://no-coal-b.local:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "separate");
assert_eq!(
counter.connections(),
2,
"coalescing disabled should open 2 connections, got {}",
counter.connections()
);
}
#[tokio::test]
async fn h2_multiplex_wait_spin_loop_many_concurrent() {
use std::sync::atomic::AtomicU32;
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (addr, counter) = h2_server_with(move |_req| {
let count = request_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("spin wait ok"))))
}
})
.await;
let client = Arc::new(
HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
);
let mut handles = Vec::new();
for i in 0..15 {
let client = client.clone();
let url = format!("http://{addr}/spinwait{i}");
handles.push(tokio::spawn(async move {
client.get(&url).unwrap().h2c_prior_knowledge().send().await
}));
}
for handle in handles {
let resp = handle.await.unwrap().unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "spin wait ok");
}
assert!(
counter.connections() <= 3,
"H2 multiplex wait should converge to few connections, got {}",
counter.connections()
);
assert_eq!(request_count.load(Ordering::SeqCst), 15);
}
#[tokio::test]
async fn forward_h2c_prior_knowledge_exercises_force_h2c() {
let (addr, _counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("forward h2c ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/h2c-forward-test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "forward h2c ok");
}
#[tokio::test]
async fn h2c_probe_cache_ttl_forces_re_probe() {
let (addr, counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("re-probed"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.h2c_probe_ttl(Duration::from_millis(1))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming1 = http::Request::builder()
.method(http::Method::GET)
.uri("/ttl-probe1")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming1)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let incoming2 = http::Request::builder()
.method(http::Method::GET)
.uri("/ttl-probe2")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming2)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "re-probed");
assert!(
counter.requests() >= 2,
"TTL expiry should cause re-probe, got {} requests",
counter.requests()
);
}
#[tokio::test]
async fn tcp_fast_open_exercises_path() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tcp_fast_open(true)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn local_address_binding_exercises_connect_bound() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.local_address(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn tcp_keepalive_interval_and_retries() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tcp_keepalive(Duration::from_secs(60))
.tcp_keepalive_interval(Duration::from_secs(30))
.tcp_keepalive_retries(5)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn switching_protocols_skips_pool_checkin() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
stream
.write_all(
b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
)
.await
.unwrap();
stream.flush().await.unwrap();
tokio::time::sleep(Duration::from_secs(5)).await;
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/ws"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::SWITCHING_PROTOCOLS);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn observer_tls_handshake_complete_event() {
aioduct_test_server::tls::install_crypto_provider();
let (addr, cert_der, _counter) = aioduct_test_server::tls::tls_h2_server().await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.request_observer(obs.clone())
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
let phases = obs.phases.lock().unwrap();
assert!(
phases.contains(&"TlsHandshakeComplete".to_string()),
"should emit TlsHandshakeComplete, got: {phases:?}"
);
assert!(
phases.contains(&"TcpConnected".to_string()),
"should emit TcpConnected, got: {phases:?}"
);
}
#[tokio::test]
async fn h2_discards_redundant_connection_on_race() {
let (addr, counter) = h2_server_with(|_req| async {
tokio::time::sleep(Duration::from_millis(2)).await;
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("race discard"))))
})
.await;
let client = Arc::new(
HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
);
let mut handles = Vec::new();
for i in 0..12 {
let client = client.clone();
let url = format!("http://{addr}/race{i}");
handles.push(tokio::spawn(async move {
client.get(&url).unwrap().h2c_prior_knowledge().send().await
}));
}
for handle in handles {
let resp = handle.await.unwrap().unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "race discard");
}
assert!(
counter.connections() <= 3,
"H2 should discard redundant connections, got {}",
counter.connections()
);
assert_eq!(counter.requests(), 12);
}
#[tokio::test]
async fn rate_limiter_wait_loop_exercises_sleep() {
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("rate ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_requests_per_sec(2)
.timeout(Duration::from_secs(10))
.build()
.unwrap();
let start = std::time::Instant::now();
for i in 0..3 {
let resp = client
.get(&format!("http://{addr}/rate{i}"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
}
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(350),
"rate limiter should delay requests, took {:?}",
elapsed
);
}
#[tokio::test]
async fn pool_hit_non_retryable_streaming_body_error() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok")
.await
.unwrap();
stream.flush().await.unwrap();
let raw = stream.into_std().unwrap();
let sock = socket2::SockRef::from(&raw);
let _ = sock.set_linger(Some(Duration::from_secs(0)));
drop(raw);
if let Ok((mut s2, _)) = listener.accept().await {
let mut buf2 = [0u8; 4096];
let _ = s2.read(&mut buf2).await;
s2.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nretried",
)
.await
.unwrap();
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let body_stream = futures_util::stream::once(async {
Ok::<_, std::convert::Infallible>(hyper::body::Frame::data(Bytes::from("streaming")))
});
let stream_body = http_body_util::StreamBody::new(body_stream);
let incoming = http::Request::builder()
.method(http::Method::POST)
.uri(format!("http://{addr}/post"))
.body(stream_body)
.unwrap();
let result = client
.forward(incoming)
.timeout(Duration::from_secs(2))
.send()
.await;
assert!(
result.is_err(),
"streaming POST on stale connection should error (non-retryable)"
);
}
#[tokio::test]
async fn forward_strip_prefix_rewrites_path() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"path={path}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/api/v1/users")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.strip_prefix("/api/v1")
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let body = resp.text().await.unwrap();
assert!(
body.contains("path=/users"),
"strip_prefix should rewrite /api/v1/users to /users, got: {body}"
);
}
#[tokio::test]
async fn pool_idle_timeout_evicts_old_connection() {
let (addr, counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("idle evict"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_millis(30))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await.unwrap();
assert!(
counter.connections() >= 2,
"idle timeout should evict connection, got {} connections",
counter.connections()
);
}
#[tokio::test]
async fn forward_on_request_and_on_response_hooks() {
let (addr, _counter) = h1_server_with(|req| async move {
let custom = req
.headers()
.get("x-hook-test")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"hook={custom}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/hook-test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.on_request(|parts| {
parts.headers.insert(
http::header::HeaderName::from_static("x-hook-test"),
http::header::HeaderValue::from_static("injected"),
);
})
.on_response(|resp| {
resp.headers_mut().insert(
http::header::HeaderName::from_static("x-resp-hook"),
http::header::HeaderValue::from_static("applied"),
);
})
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers().get("x-resp-hook").unwrap().to_str().unwrap(),
"applied"
);
let body = resp.text().await.unwrap();
assert!(
body.contains("hook=injected"),
"on_request hook should inject header, got: {body}"
);
}
#[tokio::test]
async fn chunk_download_with_range_support() {
let body_data: Vec<u8> = (0..200u8).cycle().take(1000).collect();
let body_data_arc = Arc::new(body_data.clone());
let (addr, _counter) = h1_server_with(move |req| {
let body_data = body_data_arc.clone();
async move {
if req.method() == http::Method::HEAD {
return Ok::<_, Infallible>(
Response::builder()
.header("accept-ranges", "bytes")
.header("content-length", body_data.len().to_string())
.body(Full::new(Bytes::new()))
.unwrap(),
);
}
if let Some(range) = req.headers().get("range") {
let range_str = range.to_str().unwrap();
let range_str = range_str.strip_prefix("bytes=").unwrap();
let parts: Vec<&str> = range_str.split('-').collect();
let start: usize = parts[0].parse().unwrap();
let end: usize = parts[1].parse().unwrap();
let slice = &body_data[start..=end];
return Ok(Response::builder()
.status(206)
.header(
"content-range",
format!("bytes {start}-{end}/{}", body_data.len()),
)
.body(Full::new(Bytes::copy_from_slice(slice)))
.unwrap());
}
Ok(Response::new(Full::new(Bytes::from(
body_data.as_ref().to_vec(),
))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap();
let result = client
.chunk_download(&format!("http://{addr}/file"))
.chunks(4)
.download()
.await
.unwrap();
assert_eq!(result.total_size, 1000);
assert_eq!(result.data.len(), 1000);
assert_eq!(&result.data[..], &body_data[..]);
}
#[tokio::test]
async fn chunk_download_fallback_no_ranges() {
let (addr, _counter) = h1_server_with(|req| async move {
if req.method() == http::Method::HEAD {
return Ok::<_, Infallible>(
Response::builder()
.header("content-length", "13")
.body(Full::new(Bytes::new()))
.unwrap(),
);
}
Ok(Response::new(Full::new(Bytes::from("hello aioduct"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let result = client
.chunk_download(&format!("http://{addr}/file"))
.chunks(4)
.download()
.await
.unwrap();
assert_eq!(result.total_size, 13);
assert_eq!(result.data, Bytes::from("hello aioduct"));
}
#[tokio::test]
async fn chunk_download_head_failure_returns_error() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.status(404)
.body(Full::new(Bytes::from("not found")))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let result = client
.chunk_download(&format!("http://{addr}/missing"))
.download()
.await;
assert!(result.is_err(), "HEAD failure should propagate error");
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn builder_min_tls_version_builds_successfully() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.min_tls_version(aioduct::TlsVersion::Tls1_3)
.build()
.unwrap();
let result = client.get("http://127.0.0.1:1/").unwrap().send().await;
assert!(result.is_err());
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn builder_max_tls_version_builds_successfully() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_tls_version(aioduct::TlsVersion::Tls1_2)
.build()
.unwrap();
let result = client.get("http://127.0.0.1:1/").unwrap().send().await;
assert!(result.is_err());
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn builder_tls_sni_disabled_builds_successfully() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls_sni(false)
.build()
.unwrap();
let result = client.get("http://127.0.0.1:1/").unwrap().send().await;
assert!(result.is_err());
}
#[tokio::test]
async fn forward_uses_client_timeout_when_no_explicit_timeout() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
tokio::time::sleep(Duration::from_secs(60)).await;
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_millis(100))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let result = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.send()
.await;
assert!(result.is_err(), "client timeout should fire for forward");
}
#[tokio::test]
async fn forward_preserve_host_with_upstream_base_path() {
let (addr, _counter) = h1_server_with(|req| async move {
let host = req
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
let path = req.uri().path().to_string();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"host={host},path={path}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/users/123")
.header("host", "original.example.com")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}/api/v2"))
.preserve_host()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("host=original.example.com"),
"preserve_host should keep original host, got: {body}"
);
assert!(
body.contains("path=/api/v2/users/123"),
"upstream base path should be prepended, got: {body}"
);
}
#[tokio::test]
async fn forward_remove_and_forward_header_combined() {
let (addr, _counter) = h1_server_with(|req| async move {
let auth = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
let cookie = req
.headers()
.get("cookie")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
let custom = req
.headers()
.get("x-forwarded")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"auth={auth},cookie={cookie},custom={custom}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/test")
.header("authorization", "Bearer token123")
.header("cookie", "session=abc")
.header("x-forwarded", "original-value")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.forward_header(http::header::AUTHORIZATION)
.remove_header(http::header::COOKIE)
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("auth=Bearer token123"),
"forwarded header should be present, got: {body}"
);
assert!(
body.contains("cookie=none"),
"removed header should be absent, got: {body}"
);
}
#[tokio::test]
async fn observer_receives_connection_metrics() {
let (addr, _counter) = h1_server().await;
let obs = TestObserver::default();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.request_observer(obs.clone())
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp.bytes().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let conn_events = obs.conn_events.lock().unwrap();
assert!(
!conn_events.is_empty(),
"observer should receive connection metric events"
);
let metrics_event = conn_events.iter().any(|e| e.contains("Metrics"));
assert!(
metrics_event,
"should have received a Metrics connection event, got: {conn_events:?}"
);
}
#[tokio::test]
async fn forward_adaptive_h2c_exercises_path() {
let (addr, _counter) = h2_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 adaptive"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/rpc/method")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h2 adaptive");
}
#[tokio::test]
async fn finalize_response_caches_cacheable_response() {
let hit_count = Arc::new(AtomicU32::new(0));
let hit_count_clone = hit_count.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let count = hit_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=3600")
.body(Full::new(Bytes::from("finalize cached")))
.unwrap(),
)
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.read_timeout(Duration::from_secs(30))
.max_download_speed(1024 * 1024)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/cacheable"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "finalize cached");
assert_eq!(hit_count.load(Ordering::SeqCst), 1);
let resp = client
.get(&format!("http://{addr}/cacheable"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "finalize cached");
assert_eq!(
hit_count.load(Ordering::SeqCst),
1,
"second request should be from cache"
);
}
#[tokio::test]
async fn finalize_response_applies_read_timeout_and_bandwidth_without_cache() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.body(Full::new(Bytes::from("no-cache body")))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.read_timeout(Duration::from_secs(30))
.max_download_speed(1024 * 1024)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "no-cache body");
}
#[tokio::test]
async fn not_modified_304_is_not_treated_as_redirect() {
let (addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(
Response::builder()
.status(304)
.header("etag", "\"test\"")
.body(Full::new(Bytes::new()))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::NOT_MODIFIED);
}
struct ResponseInjectMiddleware;
impl aioduct::Middleware for ResponseInjectMiddleware {
fn on_response(
&self,
response: &mut http::Response<aioduct::body::RequestBodySend>,
_uri: &http::Uri,
) {
response.headers_mut().insert(
http::header::HeaderName::from_static("x-resp-mw"),
http::header::HeaderValue::from_static("applied"),
);
}
}
#[tokio::test]
async fn middleware_on_response_applies_in_finalize() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(ResponseInjectMiddleware)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(
resp.headers().get("x-resp-mw").unwrap().to_str().unwrap(),
"applied"
);
}
#[tokio::test]
async fn cache_invalidation_on_post_variant() {
let hit_count = Arc::new(AtomicU32::new(0));
let hit_count_clone = hit_count.clone();
let (addr, _counter) = h1_server_with(move |req| {
let count = hit_count_clone.clone();
async move {
let n = count.fetch_add(1, Ordering::SeqCst);
if req.method() == http::Method::POST {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("posted"))))
} else {
Ok(Response::builder()
.header("cache-control", "max-age=3600")
.body(Full::new(Bytes::from(format!("get-{n}"))))
.unwrap())
}
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let url = format!("http://{addr}/resource");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "get-0");
let resp = client
.post(&url)
.unwrap()
.body("data")
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "posted");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "get-2");
assert_eq!(hit_count.load(Ordering::SeqCst), 3);
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn hsts_upgrade_http_to_https() {
aioduct_test_server::tls::install_crypto_provider();
let (tls_addr, cert_der, _counter) =
aioduct_test_server::tls::tls_h1_server(&[b"http/1.1"]).await;
let client_config = aioduct_test_server::tls::make_client_config(&cert_der);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let hsts = aioduct::HstsStore::new();
let mut hsts_headers = http::HeaderMap::new();
hsts_headers.insert(
"strict-transport-security",
http::header::HeaderValue::from_static("max-age=31536000"),
);
hsts.store_from_response("localhost", &hsts_headers);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.tls(connector)
.hsts(hsts)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://localhost:{}/", tls_addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert!(
resp.tls_info().is_some(),
"HSTS should upgrade to HTTPS, so TLS info should be present"
);
}
#[tokio::test]
async fn streaming_body_prevents_stale_retry() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.pool_idle_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
use http_body_util::BodyExt;
let stream_body: aioduct::body::RequestBodySend =
http_body_util::Full::new(Bytes::from("streaming data"))
.map_err(|never| match never {})
.boxed_unsync();
let resp = client
.post(&format!("http://{addr}/"))
.unwrap()
.body_stream(stream_body)
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
}
#[tokio::test]
async fn user_agent_default_does_not_override_explicit() {
let (addr, _counter) = h1_server_with(|req| async move {
let ua = req
.headers()
.get("user-agent")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!("ua={ua}")))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.user_agent("default-agent/1.0")
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.header(
http::header::USER_AGENT,
http::header::HeaderValue::from_static("override-agent/2.0"),
)
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("override-agent/2.0"),
"explicit header should override default, got: {body}"
);
}
#[tokio::test]
async fn referer_header_set_on_redirect() {
let (addr, _counter) = h1_server_with(|req| async move {
let path = req.uri().path().to_string();
if path == "/start" {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("location", "/target")
.body(Full::new(Bytes::new()))
.unwrap(),
)
} else {
let referer = req
.headers()
.get("referer")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
Ok(Response::new(Full::new(Bytes::from(format!(
"referer={referer}"
)))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.referer(true)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/start"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains(&format!("http://{addr}/start")),
"referer should contain the original URL, got: {body}"
);
}
#[tokio::test]
async fn sensitive_headers_stripped_on_cross_origin_redirect() {
let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let redirect_addr = redirect_listener.local_addr().unwrap();
let (target_addr, _counter) = h1_server_with(|req| async move {
let auth = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
let cookie = req
.headers()
.get("cookie")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "none".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"auth={auth},cookie={cookie}"
)))))
})
.await;
tokio::spawn(async move {
let (stream, _) = redirect_listener.accept().await.unwrap();
let io = aioduct::runtime::tokio_rt::TokioIo::new(stream);
let target_addr_inner = target_addr;
hyper::server::conn::http1::Builder::new()
.serve_connection(
io,
hyper::service::service_fn(move |_req: hyper::Request<hyper::body::Incoming>| {
let redirect_to = format!("http://127.0.0.1:{}/", target_addr_inner.port());
async move {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("location", redirect_to)
.body(Full::new(Bytes::new()))
.unwrap(),
)
}
}),
)
.await
.unwrap();
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.sensitive_header(http::header::HeaderName::from_static("x-secret"))
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!(
"http://localhost:{}/cross-origin",
redirect_addr.port()
))
.unwrap()
.header(
http::header::AUTHORIZATION,
http::header::HeaderValue::from_static("Bearer secret"),
)
.header(
http::header::COOKIE,
http::header::HeaderValue::from_static("session=abc"),
)
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("auth=none"),
"authorization should be stripped on cross-origin redirect, got: {body}"
);
assert!(
body.contains("cookie=none"),
"cookie should be stripped on cross-origin redirect, got: {body}"
);
}
#[tokio::test]
async fn host_header_auto_injected() {
let (addr, _counter) = h1_server_with(|req| async move {
let host = req
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_else(|| "missing".to_string());
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!(
"host={host}"
)))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains(&format!("host={addr}")),
"host header should be auto-injected, got: {body}"
);
}
#[tokio::test]
async fn empty_middleware_stack_works() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn chunk_download_debug_includes_url() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let dl = client.chunk_download("http://example.com/large.bin");
let debug = format!("{dl:?}");
assert!(debug.contains("ChunkDownload"));
assert!(debug.contains("large.bin"));
}
#[tokio::test]
async fn retry_budget_exhaustion_on_connection_error_with_middleware() {
let dead_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
port
};
let error_count = Arc::new(AtomicU32::new(0));
let error_count_clone = error_count.clone();
let retry_count = Arc::new(AtomicU32::new(0));
let retry_count_clone = retry_count.clone();
struct TrackingMiddleware {
error_count: Arc<AtomicU32>,
retry_count: Arc<AtomicU32>,
}
impl aioduct::Middleware for TrackingMiddleware {
fn on_error(&self, _error: &aioduct::Error, _uri: &http::Uri, _method: &http::Method) {
self.error_count.fetch_add(1, Ordering::SeqCst);
}
fn on_retry(
&self,
_error: &aioduct::Error,
_uri: &http::Uri,
_method: &http::Method,
_attempt: u32,
) {
self.retry_count.fetch_add(1, Ordering::SeqCst);
}
}
let budget = aioduct::RetryBudget::new(0, 1);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(TrackingMiddleware {
error_count: error_count_clone,
retry_count: retry_count_clone,
})
.build()
.unwrap();
let result = client
.get(&format!("http://127.0.0.1:{dead_port}/"))
.unwrap()
.retry(
aioduct::RetryConfig::default()
.max_retries(5)
.initial_backoff(Duration::from_millis(1))
.budget(budget),
)
.timeout(Duration::from_secs(2))
.send()
.await;
assert!(result.is_err(), "should fail when budget is exhausted");
assert!(
error_count.load(Ordering::SeqCst) >= 1,
"on_error should be called when budget exhausted, got {}",
error_count.load(Ordering::SeqCst)
);
}
#[tokio::test]
async fn retry_fully_exhausted_with_middleware_fires_error() {
let dead_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
port
};
let error_count = Arc::new(AtomicU32::new(0));
let error_count_clone = error_count.clone();
let retry_count = Arc::new(AtomicU32::new(0));
let retry_count_clone = retry_count.clone();
struct ErrorTrackMw {
error_count: Arc<AtomicU32>,
retry_count: Arc<AtomicU32>,
}
impl aioduct::Middleware for ErrorTrackMw {
fn on_error(&self, _error: &aioduct::Error, _uri: &http::Uri, _method: &http::Method) {
self.error_count.fetch_add(1, Ordering::SeqCst);
}
fn on_retry(
&self,
_error: &aioduct::Error,
_uri: &http::Uri,
_method: &http::Method,
_attempt: u32,
) {
self.retry_count.fetch_add(1, Ordering::SeqCst);
}
}
let budget = aioduct::RetryBudget::new(100, 1);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(ErrorTrackMw {
error_count: error_count_clone,
retry_count: retry_count_clone,
})
.build()
.unwrap();
let result = client
.get(&format!("http://127.0.0.1:{dead_port}/"))
.unwrap()
.retry(
aioduct::RetryConfig::default()
.max_retries(2)
.initial_backoff(Duration::from_millis(1))
.budget(budget),
)
.timeout(Duration::from_secs(5))
.send()
.await;
assert!(result.is_err(), "all retries should be exhausted");
assert_eq!(
retry_count.load(Ordering::SeqCst),
2,
"on_retry should be called for each retry attempt"
);
assert_eq!(
error_count.load(Ordering::SeqCst),
1,
"on_error should be called once when retries exhausted"
);
}
#[tokio::test]
async fn non_retryable_error_with_middleware() {
let error_count = Arc::new(AtomicU32::new(0));
let error_count_clone = error_count.clone();
struct NonRetryErrorMw {
error_count: Arc<AtomicU32>,
}
impl aioduct::Middleware for NonRetryErrorMw {
fn on_error(&self, _error: &aioduct::Error, _uri: &http::Uri, _method: &http::Method) {
self.error_count.fetch_add(1, Ordering::SeqCst);
}
}
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(NonRetryErrorMw {
error_count: error_count_clone,
})
.https_only(true)
.build()
.unwrap();
let result = client
.get("http://example.com/")
.unwrap()
.retry(
aioduct::RetryConfig::default()
.max_retries(3)
.initial_backoff(Duration::from_millis(1)),
)
.send()
.await;
assert!(result.is_err());
assert_eq!(
error_count.load(Ordering::SeqCst),
1,
"on_error should fire for non-retryable errors"
);
}
#[tokio::test]
async fn h2_multiplex_concurrent_requests_dedup() {
let (addr, counter) = h2_server_with(|_req| async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("h2 ok"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let url = format!("http://{addr}/resource");
let (r1, r2, r3) = tokio::join!(
client.get(&url).unwrap().h2c_prior_knowledge().send(),
client.get(&url).unwrap().h2c_prior_knowledge().send(),
client.get(&url).unwrap().h2c_prior_knowledge().send(),
);
assert_eq!(r1.unwrap().status(), http::StatusCode::OK);
assert_eq!(r2.unwrap().status(), http::StatusCode::OK);
assert_eq!(r3.unwrap().status(), http::StatusCode::OK);
assert_eq!(
counter.connections(),
1,
"H2 multiplexing should reuse a single connection"
);
}
#[tokio::test]
async fn stale_if_error_serves_cached_on_network_failure() {
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let attempt = attempt_clone.clone();
async move {
attempt.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=0, stale-if-error=3600")
.header("etag", "\"v1\"")
.body(Full::new(Bytes::from("cached body")))
.unwrap(),
)
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache.clone())
.timeout(Duration::from_secs(2))
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/stale-resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "cached body");
let dead_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
port
};
let client2 = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.timeout(Duration::from_millis(500))
.resolver(move |_host: &str, _port: u16| {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], dead_port));
Box::pin(async move { Ok(addr) })
as std::pin::Pin<
Box<
dyn std::future::Future<Output = std::io::Result<std::net::SocketAddr>>
+ Send,
>,
>
})
.build()
.unwrap();
let resp = client2
.get(&format!("http://{addr}/stale-resource"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "cached body");
}
#[tokio::test]
async fn adaptive_h2c_probe_error_connects_h1_fallback() {
use tokio::io::AsyncReadExt;
let connection_count = Arc::new(AtomicU32::new(0));
let connection_count_clone = connection_count.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => break,
};
let n = connection_count_clone.fetch_add(1, Ordering::SeqCst);
if n == 0 {
drop(stream);
} else {
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let response = "HTTP/1.1 200 OK\r\ncontent-length: 11\r\n\r\nh1 fallback";
use tokio::io::AsyncWriteExt;
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let incoming = http::Request::builder()
.method(http::Method::GET)
.uri("/probe-err-test")
.body(http_body_util::Empty::<Bytes>::new())
.unwrap();
let resp = client
.forward(incoming)
.upstream(format!("http://{addr}"))
.adaptive_h2c()
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "h1 fallback");
assert!(
connection_count.load(Ordering::SeqCst) >= 2,
"should open at least 2 connections (probe + fallback)"
);
}
#[tokio::test]
async fn cache_staleness_with_expired_max_age() {
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let count = request_count_clone.clone();
async move {
let n = count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
Response::builder()
.header("cache-control", "max-age=0")
.header("etag", format!("\"v{n}\""))
.body(Full::new(Bytes::from(format!("body-{n}"))))
.unwrap(),
)
}
})
.await;
let cache = aioduct::HttpCache::new();
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.cache(cache)
.build()
.unwrap();
let url = format!("http://{addr}/staleness");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.text().await.unwrap(), "body-0");
let resp = client.get(&url).unwrap().send().await.unwrap();
let body = resp.text().await.unwrap();
assert!(
request_count.load(Ordering::SeqCst) >= 2,
"stale cache should trigger revalidation"
);
assert_eq!(body, "body-1");
}
#[tokio::test]
async fn retry_on_status_budget_exhaustion_with_middleware() {
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let count = request_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
Response::builder()
.status(503)
.body(Full::new(Bytes::from("unavailable")))
.unwrap(),
)
}
})
.await;
let retry_count = Arc::new(AtomicU32::new(0));
let retry_count_clone = retry_count.clone();
struct StatusRetryMw {
retry_count: Arc<AtomicU32>,
}
impl aioduct::Middleware for StatusRetryMw {
fn on_retry(
&self,
_error: &aioduct::Error,
_uri: &http::Uri,
_method: &http::Method,
_attempt: u32,
) {
self.retry_count.fetch_add(1, Ordering::SeqCst);
}
}
let budget = aioduct::RetryBudget::new(1, 0);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.middleware(StatusRetryMw {
retry_count: retry_count_clone,
})
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.retry(
aioduct::RetryConfig::default()
.max_retries(5)
.retry_on_status(true)
.initial_backoff(Duration::from_millis(1))
.budget(budget),
)
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
request_count.load(Ordering::SeqCst),
2,
"should make original + 1 retry before budget exhaustion"
);
assert_eq!(
retry_count.load(Ordering::SeqCst),
1,
"on_retry should be called once"
);
}