use reqwest::{Client, ClientBuilder};
use std::future::Future;
use std::sync::LazyLock;
use std::time::Duration;
use crate::retry::RetryPolicy;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpTimeouts {
pub connect: Option<Duration>,
pub request: Option<Duration>,
pub download: Option<Duration>,
}
impl Default for HttpTimeouts {
fn default() -> Self {
Self {
connect: Some(DEFAULT_CONNECT_TIMEOUT),
request: Some(DEFAULT_REQUEST_TIMEOUT),
download: Some(DEFAULT_DOWNLOAD_TIMEOUT),
}
}
}
impl HttpTimeouts {
#[must_use]
pub const fn from_seconds(connect: u64, request: u64, download: u64) -> Self {
Self {
connect: duration_or_disabled(connect),
request: duration_or_disabled(request),
download: duration_or_disabled(download),
}
}
fn apply_to(self, mut builder: ClientBuilder) -> ClientBuilder {
if let Some(request) = self.request {
builder = builder.timeout(request);
}
if let Some(connect) = self.connect {
builder = builder.connect_timeout(connect);
}
builder
}
}
const fn duration_or_disabled(seconds: u64) -> Option<Duration> {
if seconds == 0 {
None
} else {
Some(Duration::from_secs(seconds))
}
}
static HTTP_TIMEOUTS: LazyLock<arc_swap::ArcSwap<HttpTimeouts>> =
LazyLock::new(|| arc_swap::ArcSwap::from_pointee(HttpTimeouts::default()));
pub fn install_http_timeouts(timeouts: HttpTimeouts) {
HTTP_TIMEOUTS.store(std::sync::Arc::new(timeouts));
}
#[must_use]
pub fn http_timeouts() -> HttpTimeouts {
**HTTP_TIMEOUTS.load()
}
fn apply_platform_proxy_policy(builder: ClientBuilder) -> ClientBuilder {
#[cfg(target_os = "macos")]
{
builder.no_proxy()
}
#[cfg(not(target_os = "macos"))]
{
builder
}
}
fn try_build_client<F>(configure: F) -> Result<Client, reqwest::Error>
where
F: Fn(ClientBuilder) -> ClientBuilder,
{
let primary_builder = configure(apply_platform_proxy_policy(ClientBuilder::new()));
match primary_builder.build() {
Ok(client) => Ok(client),
Err(primary_err) => {
let fallback_builder = HttpTimeouts::default().apply_to(apply_platform_proxy_policy(ClientBuilder::new()));
match fallback_builder.build() {
Ok(client) => Ok(client),
Err(fallback_err) => {
tracing::error!(
primary_error = %primary_err,
fallback_error = %fallback_err,
"HTTP client creation failed with both primary and fallback configurations"
);
Err(fallback_err)
}
}
}
}
}
fn build_client<F>(configure: F) -> Client
where
F: Fn(ClientBuilder) -> ClientBuilder,
{
try_build_client(configure).unwrap_or_else(|e| {
tracing::error!(
error = %e,
"HTTP client build failed (primary + fallback); falling back to bare default client. \
This usually indicates a TLS configuration issue or system resource exhaustion."
);
Client::new()
})
}
pub fn create_default_client() -> Client {
build_client(|builder| http_timeouts().apply_to(builder))
}
pub fn create_client_with_timeout(timeout: Duration) -> Client {
let connect = http_timeouts().connect;
build_client(move |builder| {
let builder = builder.timeout(timeout);
match connect {
Some(connect) => builder.connect_timeout(connect),
None => builder,
}
})
}
pub fn create_client_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Client {
build_client(|builder| builder.timeout(request_timeout).connect_timeout(connect_timeout))
}
pub fn create_client_without_redirects(
connect_timeout: Duration,
request_timeout: Duration,
) -> Result<Client, reqwest::Error> {
apply_platform_proxy_policy(ClientBuilder::new())
.timeout(request_timeout)
.connect_timeout(connect_timeout)
.redirect(reqwest::redirect::Policy::none())
.build()
}
pub fn create_default_client_without_redirects() -> Result<Client, reqwest::Error> {
let timeouts = http_timeouts();
let mut builder = apply_platform_proxy_policy(ClientBuilder::new()).redirect(reqwest::redirect::Policy::none());
if let Some(request) = timeouts.request {
builder = builder.timeout(request);
}
if let Some(connect) = timeouts.connect {
builder = builder.connect_timeout(connect);
}
builder.build()
}
pub fn create_client_with_user_agent(user_agent: &str) -> Client {
let timeouts = http_timeouts();
build_client(move |mut builder| {
builder = builder.user_agent(user_agent);
timeouts.apply_to(builder)
})
}
pub fn create_streaming_client() -> Client {
let connect = http_timeouts().connect;
build_client(move |builder| {
let builder = builder.tcp_keepalive(Some(Duration::from_secs(60)));
match connect {
Some(connect) => builder.connect_timeout(connect),
None => builder,
}
})
}
pub fn create_download_client(user_agent: &str) -> Client {
let timeouts = http_timeouts();
build_client(move |mut builder| {
builder = builder.user_agent(user_agent).tcp_keepalive(Some(Duration::from_secs(60)));
if let Some(download) = timeouts.download {
builder = builder.timeout(download);
}
if let Some(connect) = timeouts.connect {
builder = builder.connect_timeout(connect);
}
builder
})
}
pub fn get_or_create_default_client(existing: Option<Client>) -> Client {
existing.unwrap_or_else(create_default_client)
}
pub fn apply_connect_timeout(builder: ClientBuilder) -> ClientBuilder {
match http_timeouts().connect {
Some(connect) => builder.connect_timeout(connect),
None => builder,
}
}
pub async fn send_with_retry<F, Fut>(policy: &RetryPolicy, make_request: F) -> Result<reqwest::Response, reqwest::Error>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
send_with_retry_inner(policy, true, make_request).await
}
pub async fn send_with_retry_transport_only<F, Fut>(
policy: &RetryPolicy,
make_request: F,
) -> Result<reqwest::Response, reqwest::Error>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
send_with_retry_inner(policy, false, make_request).await
}
async fn send_with_retry_inner<F, Fut>(
policy: &RetryPolicy,
retry_status: bool,
make_request: F,
) -> Result<reqwest::Response, reqwest::Error>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
let attempts = policy.max_attempts.max(1);
let mut attempt: u32 = 0;
loop {
if attempt > 0 {
tokio::time::sleep(policy.delay_for_attempt(attempt - 1)).await;
}
let last = attempt + 1 >= attempts;
match make_request().await {
Ok(response) if !retry_status || last || !policy.classify_status(response.status().as_u16()).retryable => {
return Ok(response);
}
Ok(_) => {}
Err(error) if error.is_builder() || last => return Err(error),
Err(_) => {}
}
attempt += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_timeouts_match_historical_constants() {
let defaults = HttpTimeouts::default();
assert_eq!(defaults.connect, Some(Duration::from_secs(5)));
assert_eq!(defaults.request, Some(Duration::from_secs(30)));
assert_eq!(defaults.download, Some(Duration::from_secs(300)));
}
#[test]
fn zero_seconds_disables_the_phase() {
let timeouts = HttpTimeouts::from_seconds(0, 0, 0);
assert_eq!(timeouts.connect, None);
assert_eq!(timeouts.request, None);
assert_eq!(timeouts.download, None);
}
#[test]
fn from_seconds_preserves_explicit_values() {
let timeouts = HttpTimeouts::from_seconds(12, 45, 900);
assert_eq!(timeouts.connect, Some(Duration::from_secs(12)));
assert_eq!(timeouts.request, Some(Duration::from_secs(45)));
assert_eq!(timeouts.download, Some(Duration::from_secs(900)));
}
struct InstalledTimeoutsGuard(HttpTimeouts);
impl InstalledTimeoutsGuard {
fn recording_current() -> Self {
Self(http_timeouts())
}
}
impl Drop for InstalledTimeoutsGuard {
fn drop(&mut self) {
install_http_timeouts(self.0);
}
}
#[test]
#[serial_test::serial]
fn installing_timeouts_is_observable_and_restorable() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(0, 7, 11));
assert_eq!(http_timeouts().connect, None);
assert_eq!(http_timeouts().request, Some(Duration::from_secs(7)));
assert_eq!(http_timeouts().download, Some(Duration::from_secs(11)));
}
#[test]
#[serial_test::serial]
fn clients_build_for_every_timeout_shape() {
let _guard = InstalledTimeoutsGuard::recording_current();
for timeouts in [
HttpTimeouts::default(),
HttpTimeouts::from_seconds(0, 0, 0),
HttpTimeouts::from_seconds(1, 1, 1),
] {
install_http_timeouts(timeouts);
drop(create_default_client());
drop(create_client_with_timeout(Duration::from_secs(3)));
drop(create_streaming_client());
drop(create_client_with_user_agent("vtcode-test"));
drop(create_download_client("vtcode-test"));
let origin_bound =
create_default_client_without_redirects().expect("client without redirects should build");
drop(origin_bound);
}
}
const UNROUTABLE_URL: &str = "http://198.51.100.1:9/hang";
const STALL_TEST_OUTER_GUARD: Duration = Duration::from_secs(15);
fn bind_fixture_listener() -> Option<std::net::TcpListener> {
match std::net::TcpListener::bind("127.0.0.1:0") {
Ok(listener) => Some(listener),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
Err(err) => panic!("bind fixture listener: {err}"),
}
}
fn stalled_listener() -> Option<std::net::TcpListener> {
bind_fixture_listener()
}
fn respond_once(listener: std::net::TcpListener) {
drop(std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept test connection");
use std::io::{Read, Write};
let mut request = [0u8; 4096];
let request_bytes = stream.read(&mut request).expect("read test request");
assert!(request_bytes > 0, "client must send the request");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.expect("write test response");
}));
}
#[tokio::test]
#[serial_test::serial]
async fn default_client_request_budget_bounds_an_accept_then_stall_server() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
let Some(listener) = stalled_listener() else { return };
let addr = listener.local_addr().expect("listener address");
let client = create_default_client();
let started = std::time::Instant::now();
let outcome =
tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
let elapsed = started.elapsed();
let error = outcome
.expect("request budget must fire before the outer guard")
.expect_err("a stalled server must not succeed");
assert!(error.is_timeout(), "expected a timeout error, got: {error}");
assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
}
#[tokio::test]
#[serial_test::serial]
async fn default_client_connect_phase_cannot_hang_on_unroutable_host() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
let client = create_default_client();
let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
let error = outcome
.expect("connect phase must resolve before the outer guard")
.expect_err("an unroutable endpoint must not succeed");
assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
}
#[tokio::test]
#[serial_test::serial]
async fn origin_bound_client_request_budget_bounds_a_stalled_server() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
let Some(listener) = stalled_listener() else { return };
let addr = listener.local_addr().expect("listener address");
let client = create_default_client_without_redirects().expect("origin-bound client builds");
let started = std::time::Instant::now();
let outcome =
tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
let elapsed = started.elapsed();
let error = outcome
.expect("request budget must fire before the outer guard")
.expect_err("a stalled server must not succeed");
assert!(error.is_timeout(), "expected a timeout error, got: {error}");
assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
}
#[tokio::test]
#[serial_test::serial]
async fn bounded_clients_still_succeed_against_a_healthy_server() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(5, 5, 300));
let Some(listener) = bind_fixture_listener() else {
return;
};
let addr = listener.local_addr().expect("listener address");
respond_once(listener);
let outcome =
tokio::time::timeout(STALL_TEST_OUTER_GUARD, create_default_client().get(format!("http://{addr}/")).send())
.await;
let body = outcome
.expect("healthy server must answer before the outer guard")
.expect("bounded client must succeed")
.text()
.await
.expect("read healthy body");
assert_eq!(body, "ok");
}
#[tokio::test]
#[serial_test::serial]
async fn apply_connect_timeout_bounds_an_explicit_builder() {
let _guard = InstalledTimeoutsGuard::recording_current();
install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
let client = apply_connect_timeout(Client::builder())
.build()
.expect("explicit builder builds");
let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
let error = outcome
.expect("connect phase must resolve before the outer guard")
.expect_err("an unroutable endpoint must not succeed");
assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
}
fn respond_scripted(listener: std::net::TcpListener, statuses: Vec<u16>) {
drop(std::thread::spawn(move || {
use std::io::{Read, Write};
for status in statuses {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut request = [0u8; 4096];
if stream.read(&mut request).is_err() {
break;
}
let response = format!("HTTP/1.1 {status} Test\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
if stream.write_all(response.as_bytes()).is_err() {
break;
}
}
}));
}
fn reserve_refused_port() -> Option<u16> {
match std::net::TcpListener::bind("127.0.0.1:0") {
Ok(listener) => Some(listener.local_addr().expect("listener address").port()),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
Err(err) => panic!("reserve refused port: {err}"),
}
}
fn retry_test_policy() -> RetryPolicy {
RetryPolicy::from_retries(2, Duration::from_millis(1), Duration::from_millis(5), 2.0)
.with_jitter(RetryPolicy::DEFAULT_JITTER)
}
fn retry_test_client() -> Client {
create_client_with_timeouts(Duration::from_secs(5), Duration::from_secs(5))
}
#[tokio::test]
async fn retryable_status_is_retried_until_success() {
let Some(listener) = bind_fixture_listener() else {
return;
};
let addr = listener.local_addr().expect("listener address");
respond_scripted(listener, vec![503, 429, 200]);
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = retry_test_policy();
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://{addr}/")).send()
}),
)
.await
.expect("scripted server must answer before the outer guard")
.expect("503,429,200 must recover");
assert_eq!(outcome.status().as_u16(), 200);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn exhausted_status_budget_returns_the_final_response() {
let Some(listener) = bind_fixture_listener() else {
return;
};
let addr = listener.local_addr().expect("listener address");
respond_scripted(listener, vec![503, 503, 503, 503]);
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = retry_test_policy();
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://{addr}/")).send()
}),
)
.await
.expect("scripted server must answer before the outer guard")
.expect("exhaustion returns the response, not an error");
assert_eq!(outcome.status().as_u16(), 503);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn non_retryable_status_returns_immediately() {
let Some(listener) = bind_fixture_listener() else {
return;
};
let addr = listener.local_addr().expect("listener address");
respond_scripted(listener, vec![404, 200]);
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = retry_test_policy();
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://{addr}/")).send()
}),
)
.await
.expect("scripted server must answer before the outer guard")
.expect("404 must arrive as a response");
assert_eq!(outcome.status().as_u16(), 404);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn transport_failure_is_retried_then_returned() {
let Some(port) = reserve_refused_port() else {
return;
};
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = retry_test_policy();
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://127.0.0.1:{port}/")).send()
}),
)
.await
.expect("refused connections must resolve before the outer guard");
assert!(outcome.is_err(), "a refused port must not succeed");
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn builder_error_fails_fast_without_retry() {
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = retry_test_policy();
let outcome = send_with_retry(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get("http://exa mple.invalid/").send()
})
.await;
let error = outcome.expect_err("a malformed URL must not succeed");
assert!(error.is_builder(), "expected a builder error, got: {error}");
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn transport_only_helper_ignores_retryable_status() {
let Some(listener) = bind_fixture_listener() else {
return;
};
let addr = listener.local_addr().expect("listener address");
respond_scripted(listener, vec![503, 200]);
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
.with_jitter(RetryPolicy::DEFAULT_JITTER);
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry_transport_only(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://{addr}/")).send()
}),
)
.await
.expect("scripted server must answer before the outer guard")
.expect("503 must arrive as a response");
assert_eq!(outcome.status().as_u16(), 503);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn transport_only_helper_still_retries_transport_failure() {
let Some(port) = reserve_refused_port() else {
return;
};
let client = retry_test_client();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
.with_jitter(RetryPolicy::DEFAULT_JITTER);
let outcome = tokio::time::timeout(
STALL_TEST_OUTER_GUARD,
send_with_retry_transport_only(&policy, || {
let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
client.get(format!("http://127.0.0.1:{port}/")).send()
}),
)
.await
.expect("refused connections must resolve before the outer guard");
assert!(outcome.is_err(), "a refused port must not succeed");
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}