use reqwest::{Client, ClientBuilder};
use std::sync::LazyLock;
use std::time::Duration;
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,
}
}
#[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");
}
}