#![cfg(feature = "tokio")]
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
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::runtime::TokioRuntime;
use aioduct::runtime::tokio_rt::TcpConnector;
use aioduct_test_server::h1::{h1_server, h1_server_with};
use aioduct_test_server::raw::raw_server;
#[tokio::test]
async fn test_connection_refused() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let result = client.get("http://127.0.0.1:1/").unwrap().send().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_client_clone_shares_pool() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let cloned = client.clone();
let resp1 = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let _ = resp1.text().await.unwrap();
let resp2 = cloned
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp2.status(), http::StatusCode::OK);
let body = resp2.text().await.unwrap();
assert_eq!(body, "hello aioduct");
}
#[tokio::test]
async fn test_concurrent_requests() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let mut handles = Vec::new();
for _ in 0..10 {
let client = client.clone();
let url = format!("http://{addr}/");
handles.push(tokio::spawn(async move {
client
.get(&url)
.unwrap()
.send()
.await
.unwrap()
.text()
.await
.unwrap()
}));
}
for handle in handles {
let body = handle.await.unwrap();
assert_eq!(body, "hello aioduct");
}
}
#[tokio::test]
async fn test_no_connection_reuse() {
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();
for _ in 0..3 {
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), 3);
}
#[tokio::test]
async fn test_remote_addr_is_set() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let remote = resp.remote_addr();
assert!(remote.is_some(), "remote_addr should be set");
assert_eq!(remote.unwrap().port(), addr.port());
}
#[tokio::test]
async fn test_response_content_length() {
let body = "x".repeat(42);
let body_clone = body.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let body = body_clone.clone();
async move { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body)))) }
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.content_length(), Some(42));
}
#[tokio::test]
async fn test_response_version() {
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.version(), http::Version::HTTP_11);
}
#[tokio::test]
async fn test_error_for_status_integration() {
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>::new();
let resp = client
.get(&format!("http://{addr}/missing"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
let result = resp.error_for_status();
assert!(result.is_err());
}
#[tokio::test]
async fn test_response_url_after_redirect() {
let (final_addr, _counter) = h1_server().await;
let (redirect_addr, _counter) = h1_server_with(move |_req| {
let target = format!("http://{final_addr}/final");
async move {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("location", target)
.body(Full::new(Bytes::new()))
.unwrap(),
)
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{redirect_addr}/start"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let url = resp.url().to_string();
assert!(
url.contains("/final"),
"url should reflect final destination after redirect, got: {url}"
);
}
#[tokio::test]
async fn test_client_debug() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let dbg = format!("{client:?}");
assert!(dbg.contains("HttpEngineSend"));
}
#[tokio::test]
async fn test_rate_limiter_throttles() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.rate_limiter(aioduct::RateLimiter::new(100, Duration::from_secs(1)))
.build()
.unwrap();
let start = tokio::time::Instant::now();
for _ in 0..3 {
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.text().await;
}
let elapsed = start.elapsed();
assert!(elapsed < Duration::from_secs(1));
}
#[tokio::test]
async fn test_rate_limiter_sleep_path() {
let (addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.rate_limiter(aioduct::RateLimiter::new(1, Duration::from_millis(200)))
.build()
.unwrap();
let start = tokio::time::Instant::now();
for i in 0..3 {
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::OK,
"request {i} should succeed"
);
let _ = resp.text().await;
}
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(300),
"1 req per 200ms → 3 requests should take at least 300ms, got {elapsed:?}"
);
}
#[tokio::test]
async fn test_bandwidth_limiter_download() {
let data = "x".repeat(500);
let data_clone = data.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let data = data_clone.clone();
async move { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(data)))) }
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.max_download_speed(100_000)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert!(client.bandwidth_limiter().is_some());
let body = resp.text().await.unwrap();
assert_eq!(body.len(), 500);
}
#[tokio::test]
async fn test_https_only_rejects_http() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.https_only(true)
.build()
.unwrap();
let result = client.get("http://example.com/").unwrap().send().await;
assert!(result.is_err());
let err = format!("{:?}", result.unwrap_err());
assert!(
err.contains("HttpsOnly") || err.contains("http"),
"expected https-only error, got: {err}"
);
}
#[tokio::test]
async fn overridden_dns_resolution() {
use std::pin::Pin;
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("resolved"))))
})
.await;
let overridden_domain = "rust-lang.test";
let port = addr.port();
let target_addr = addr;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.resolver(
move |_host: &str,
_port: u16|
-> Pin<
Box<dyn std::future::Future<Output = std::io::Result<SocketAddr>> + Send>,
> {
let target = target_addr;
Box::pin(async move { Ok(target) })
},
)
.build()
.unwrap();
let url = format!("http://{overridden_domain}:{port}/");
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let text = resp.text().await.unwrap();
assert_eq!(text, "resolved");
}
#[tokio::test]
async fn resolve_builder_convenience() {
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(
"resolved via builder",
))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.resolve("my-custom-host.test", addr)
.build()
.unwrap();
let url = format!("http://my-custom-host.test:{}/path", addr.port());
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "resolved via builder");
}
#[tokio::test]
async fn resolve_to_addrs_builder_convenience() {
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("multi-addr"))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.resolve_to_addrs("multi.test", &[addr])
.build()
.unwrap();
let url = format!("http://multi.test:{}/", addr.port());
let resp = client.get(&url).unwrap().send().await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "multi-addr");
}
#[tokio::test]
async fn close_connection_after_idle_timeout() {
let request_count = Arc::new(AtomicU32::new(0));
let count_clone = request_count.clone();
let (addr, _counter) = h1_server_with(move |_req| {
let count = 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()
.pool_idle_timeout(Duration::from_millis(100))
.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(300)).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_eq!(request_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn error_connection_refused_with_url() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let result = client.get("http://127.0.0.1:1/path").unwrap().send().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.is_connect(), "expected connect error, got: {err:?}");
}
#[tokio::test]
async fn error_for_status_with_reason_phrase() {
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(
Response::builder()
.status(418)
.body(Full::new(Bytes::new()))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 418);
let err = resp.error_for_status().unwrap_err();
assert!(err.is_status());
assert_eq!(err.status(), Some(http::StatusCode::from_u16(418).unwrap()));
}
#[tokio::test]
async fn error_carries_url_context() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let expected_remote = "127.0.0.1:1".parse().unwrap();
let err = client
.get("http://127.0.0.1:1/the-path")
.unwrap()
.send()
.await
.unwrap_err();
assert_eq!(err.url().path(), "/the-path");
assert_eq!(err.remote_addr(), Some(expected_remote));
assert!(err.is_connect());
let display = format!("{err}");
assert!(
display.contains("/the-path"),
"Display should include URL, got: {display}"
);
let inner: aioduct::Error = err.into();
assert!(inner.is_connect());
}
#[tokio::test]
async fn error_for_status_display_includes_status_code() {
let (addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(
Response::builder()
.status(418)
.body(Full::new(Bytes::new()))
.unwrap(),
)
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
let err = resp.error_for_status().unwrap_err();
let display = format!("{err}");
assert!(
display.contains("418"),
"error_for_status Display should include the status code, got: {display}"
);
}
#[tokio::test]
async fn send_error_display_includes_url_for_connection_error() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let err = client
.get("http://127.0.0.1:1/test-path")
.unwrap()
.send()
.await
.unwrap_err();
let display = format!("{err}");
assert!(
display.contains("/test-path"),
"SendError Display should include URL, got: {display}"
);
}
#[tokio::test]
async fn user_agent_builder() {
let (addr, _counter) = h1_server_with(|req| async move {
let ua = req
.headers()
.get("user-agent")
.map(|v| v.to_str().unwrap().to_owned())
.unwrap_or_default();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(ua))))
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.user_agent("aioduct-test/1.0")
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "aioduct-test/1.0");
}
#[tokio::test]
async fn default_headers_applied() {
let (addr, _counter) = h1_server_with(|req| async move {
let val = req
.headers()
.get("x-custom-default")
.map(|v| v.to_str().unwrap().to_owned())
.unwrap_or_default();
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()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "default-value");
}
#[tokio::test]
async fn request_header_overrides_default_header() {
let (addr, _counter) = h1_server_with(|req| async move {
let val = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_owned())
.unwrap_or_default();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(val))))
})
.await;
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
http::header::HeaderValue::from_static("default-token"),
);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.default_headers(headers)
.build()
.unwrap();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.header(
http::header::AUTHORIZATION,
http::header::HeaderValue::from_static("override-token"),
)
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "override-token");
}
#[tokio::test]
async fn http1_reason_phrase_in_status() {
let addr = raw_server(|_req| async {
b"HTTP/1.1 418 I'm a Teapot\r\nContent-Length: 0\r\n\r\n".to_vec()
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::new();
let resp = client
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 418);
}