1use reqwest::{Client, ClientBuilder};
13use std::sync::LazyLock;
14use std::time::Duration;
15
16const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
18const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
20const DEFAULT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct HttpTimeouts {
31 pub connect: Option<Duration>,
33 pub request: Option<Duration>,
35 pub download: Option<Duration>,
37}
38
39impl Default for HttpTimeouts {
40 fn default() -> Self {
41 Self {
42 connect: Some(DEFAULT_CONNECT_TIMEOUT),
43 request: Some(DEFAULT_REQUEST_TIMEOUT),
44 download: Some(DEFAULT_DOWNLOAD_TIMEOUT),
45 }
46 }
47}
48
49impl HttpTimeouts {
50 #[must_use]
53 pub const fn from_seconds(connect: u64, request: u64, download: u64) -> Self {
54 Self {
55 connect: duration_or_disabled(connect),
56 request: duration_or_disabled(request),
57 download: duration_or_disabled(download),
58 }
59 }
60
61 fn apply_to(self, mut builder: ClientBuilder) -> ClientBuilder {
64 if let Some(request) = self.request {
65 builder = builder.timeout(request);
66 }
67 if let Some(connect) = self.connect {
68 builder = builder.connect_timeout(connect);
69 }
70 builder
71 }
72}
73
74const fn duration_or_disabled(seconds: u64) -> Option<Duration> {
77 if seconds == 0 {
78 None
79 } else {
80 Some(Duration::from_secs(seconds))
81 }
82}
83
84static HTTP_TIMEOUTS: LazyLock<arc_swap::ArcSwap<HttpTimeouts>> =
85 LazyLock::new(|| arc_swap::ArcSwap::from_pointee(HttpTimeouts::default()));
86
87pub fn install_http_timeouts(timeouts: HttpTimeouts) {
93 HTTP_TIMEOUTS.store(std::sync::Arc::new(timeouts));
94}
95
96#[must_use]
98pub fn http_timeouts() -> HttpTimeouts {
99 **HTTP_TIMEOUTS.load()
100}
101
102fn apply_platform_proxy_policy(builder: ClientBuilder) -> ClientBuilder {
103 #[cfg(target_os = "macos")]
104 {
105 builder.no_proxy()
107 }
108 #[cfg(not(target_os = "macos"))]
109 {
110 builder
111 }
112}
113
114fn try_build_client<F>(configure: F) -> Result<Client, reqwest::Error>
119where
120 F: Fn(ClientBuilder) -> ClientBuilder,
121{
122 let primary_builder = configure(apply_platform_proxy_policy(ClientBuilder::new()));
123 match primary_builder.build() {
124 Ok(client) => Ok(client),
125 Err(primary_err) => {
126 let fallback_builder = HttpTimeouts::default().apply_to(apply_platform_proxy_policy(ClientBuilder::new()));
127 match fallback_builder.build() {
128 Ok(client) => Ok(client),
129 Err(fallback_err) => {
130 tracing::error!(
131 primary_error = %primary_err,
132 fallback_error = %fallback_err,
133 "HTTP client creation failed with both primary and fallback configurations"
134 );
135 Err(fallback_err)
136 }
137 }
138 }
139 }
140}
141
142fn build_client<F>(configure: F) -> Client
150where
151 F: Fn(ClientBuilder) -> ClientBuilder,
152{
153 try_build_client(configure).unwrap_or_else(|e| {
154 tracing::error!(
155 error = %e,
156 "HTTP client build failed (primary + fallback); falling back to bare default client. \
157 This usually indicates a TLS configuration issue or system resource exhaustion."
158 );
159 Client::new()
160 })
161}
162
163pub fn create_default_client() -> Client {
165 build_client(|builder| http_timeouts().apply_to(builder))
166}
167
168pub fn create_client_with_timeout(timeout: Duration) -> Client {
173 let connect = http_timeouts().connect;
174 build_client(move |builder| {
175 let builder = builder.timeout(timeout);
176 match connect {
177 Some(connect) => builder.connect_timeout(connect),
178 None => builder,
179 }
180 })
181}
182
183pub fn create_client_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Client {
185 build_client(|builder| builder.timeout(request_timeout).connect_timeout(connect_timeout))
186}
187
188pub fn create_client_without_redirects(
190 connect_timeout: Duration,
191 request_timeout: Duration,
192) -> Result<Client, reqwest::Error> {
193 apply_platform_proxy_policy(ClientBuilder::new())
194 .timeout(request_timeout)
195 .connect_timeout(connect_timeout)
196 .redirect(reqwest::redirect::Policy::none())
197 .build()
198}
199
200pub fn create_default_client_without_redirects() -> Result<Client, reqwest::Error> {
205 let timeouts = http_timeouts();
206 let mut builder = apply_platform_proxy_policy(ClientBuilder::new()).redirect(reqwest::redirect::Policy::none());
207 if let Some(request) = timeouts.request {
208 builder = builder.timeout(request);
209 }
210 if let Some(connect) = timeouts.connect {
211 builder = builder.connect_timeout(connect);
212 }
213 builder.build()
214}
215
216pub fn create_client_with_user_agent(user_agent: &str) -> Client {
218 let timeouts = http_timeouts();
219 build_client(move |mut builder| {
220 builder = builder.user_agent(user_agent);
221 timeouts.apply_to(builder)
222 })
223}
224
225pub fn create_streaming_client() -> Client {
232 let connect = http_timeouts().connect;
233 build_client(move |builder| {
234 let builder = builder.tcp_keepalive(Some(Duration::from_secs(60)));
235 match connect {
236 Some(connect) => builder.connect_timeout(connect),
237 None => builder,
238 }
239 })
240}
241
242pub fn create_download_client(user_agent: &str) -> Client {
249 let timeouts = http_timeouts();
250 build_client(move |mut builder| {
251 builder = builder.user_agent(user_agent).tcp_keepalive(Some(Duration::from_secs(60)));
252 if let Some(download) = timeouts.download {
253 builder = builder.timeout(download);
254 }
255 if let Some(connect) = timeouts.connect {
256 builder = builder.connect_timeout(connect);
257 }
258 builder
259 })
260}
261
262pub fn get_or_create_default_client(existing: Option<Client>) -> Client {
264 existing.unwrap_or_else(create_default_client)
265}
266
267pub fn apply_connect_timeout(builder: ClientBuilder) -> ClientBuilder {
273 match http_timeouts().connect {
274 Some(connect) => builder.connect_timeout(connect),
275 None => builder,
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn default_timeouts_match_historical_constants() {
285 let defaults = HttpTimeouts::default();
286 assert_eq!(defaults.connect, Some(Duration::from_secs(5)));
287 assert_eq!(defaults.request, Some(Duration::from_secs(30)));
288 assert_eq!(defaults.download, Some(Duration::from_secs(300)));
289 }
290
291 #[test]
292 fn zero_seconds_disables_the_phase() {
293 let timeouts = HttpTimeouts::from_seconds(0, 0, 0);
294 assert_eq!(timeouts.connect, None);
295 assert_eq!(timeouts.request, None);
296 assert_eq!(timeouts.download, None);
297 }
298
299 #[test]
300 fn from_seconds_preserves_explicit_values() {
301 let timeouts = HttpTimeouts::from_seconds(12, 45, 900);
302 assert_eq!(timeouts.connect, Some(Duration::from_secs(12)));
303 assert_eq!(timeouts.request, Some(Duration::from_secs(45)));
304 assert_eq!(timeouts.download, Some(Duration::from_secs(900)));
305 }
306
307 struct InstalledTimeoutsGuard(HttpTimeouts);
310
311 impl InstalledTimeoutsGuard {
312 fn recording_current() -> Self {
313 Self(http_timeouts())
314 }
315 }
316
317 impl Drop for InstalledTimeoutsGuard {
318 fn drop(&mut self) {
319 install_http_timeouts(self.0);
320 }
321 }
322
323 #[test]
326 #[serial_test::serial]
327 fn installing_timeouts_is_observable_and_restorable() {
328 let _guard = InstalledTimeoutsGuard::recording_current();
329
330 install_http_timeouts(HttpTimeouts::from_seconds(0, 7, 11));
331 assert_eq!(http_timeouts().connect, None);
332 assert_eq!(http_timeouts().request, Some(Duration::from_secs(7)));
333 assert_eq!(http_timeouts().download, Some(Duration::from_secs(11)));
334 }
335
336 #[test]
337 #[serial_test::serial]
338 fn clients_build_for_every_timeout_shape() {
339 let _guard = InstalledTimeoutsGuard::recording_current();
340
341 for timeouts in [
343 HttpTimeouts::default(),
344 HttpTimeouts::from_seconds(0, 0, 0),
345 HttpTimeouts::from_seconds(1, 1, 1),
346 ] {
347 install_http_timeouts(timeouts);
348 drop(create_default_client());
349 drop(create_client_with_timeout(Duration::from_secs(3)));
350 drop(create_streaming_client());
351 drop(create_client_with_user_agent("vtcode-test"));
352 drop(create_download_client("vtcode-test"));
353 let origin_bound =
354 create_default_client_without_redirects().expect("client without redirects should build");
355 drop(origin_bound);
356 }
357 }
358
359 const UNROUTABLE_URL: &str = "http://198.51.100.1:9/hang";
362
363 const STALL_TEST_OUTER_GUARD: Duration = Duration::from_secs(15);
367
368 fn bind_fixture_listener() -> Option<std::net::TcpListener> {
372 match std::net::TcpListener::bind("127.0.0.1:0") {
373 Ok(listener) => Some(listener),
374 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
375 Err(err) => panic!("bind fixture listener: {err}"),
376 }
377 }
378
379 fn stalled_listener() -> Option<std::net::TcpListener> {
384 bind_fixture_listener()
385 }
386
387 fn respond_once(listener: std::net::TcpListener) {
391 drop(std::thread::spawn(move || {
392 let (mut stream, _) = listener.accept().expect("accept test connection");
393 use std::io::{Read, Write};
394 let mut request = [0u8; 4096];
395 let request_bytes = stream.read(&mut request).expect("read test request");
396 assert!(request_bytes > 0, "client must send the request");
397 stream
398 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
399 .expect("write test response");
400 }));
401 }
402
403 #[tokio::test]
404 #[serial_test::serial]
405 async fn default_client_request_budget_bounds_an_accept_then_stall_server() {
406 let _guard = InstalledTimeoutsGuard::recording_current();
407 install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
408
409 let Some(listener) = stalled_listener() else { return };
410 let addr = listener.local_addr().expect("listener address");
411
412 let client = create_default_client();
413 let started = std::time::Instant::now();
414 let outcome =
415 tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
416 let elapsed = started.elapsed();
417
418 let error = outcome
419 .expect("request budget must fire before the outer guard")
420 .expect_err("a stalled server must not succeed");
421 assert!(error.is_timeout(), "expected a timeout error, got: {error}");
422 assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
423 }
424
425 #[tokio::test]
426 #[serial_test::serial]
427 async fn default_client_connect_phase_cannot_hang_on_unroutable_host() {
428 let _guard = InstalledTimeoutsGuard::recording_current();
429 install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
430
431 let client = create_default_client();
432 let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
433
434 let error = outcome
438 .expect("connect phase must resolve before the outer guard")
439 .expect_err("an unroutable endpoint must not succeed");
440 assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
441 }
442
443 #[tokio::test]
444 #[serial_test::serial]
445 async fn origin_bound_client_request_budget_bounds_a_stalled_server() {
446 let _guard = InstalledTimeoutsGuard::recording_current();
447 install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
448
449 let Some(listener) = stalled_listener() else { return };
450 let addr = listener.local_addr().expect("listener address");
451
452 let client = create_default_client_without_redirects().expect("origin-bound client builds");
453 let started = std::time::Instant::now();
454 let outcome =
455 tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
456 let elapsed = started.elapsed();
457
458 let error = outcome
459 .expect("request budget must fire before the outer guard")
460 .expect_err("a stalled server must not succeed");
461 assert!(error.is_timeout(), "expected a timeout error, got: {error}");
462 assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
463 }
464
465 #[tokio::test]
466 #[serial_test::serial]
467 async fn bounded_clients_still_succeed_against_a_healthy_server() {
468 let _guard = InstalledTimeoutsGuard::recording_current();
469 install_http_timeouts(HttpTimeouts::from_seconds(5, 5, 300));
470
471 let Some(listener) = bind_fixture_listener() else {
472 return;
473 };
474 let addr = listener.local_addr().expect("listener address");
475 respond_once(listener);
476
477 let outcome =
478 tokio::time::timeout(STALL_TEST_OUTER_GUARD, create_default_client().get(format!("http://{addr}/")).send())
479 .await;
480 let body = outcome
481 .expect("healthy server must answer before the outer guard")
482 .expect("bounded client must succeed")
483 .text()
484 .await
485 .expect("read healthy body");
486 assert_eq!(body, "ok");
487 }
488
489 #[tokio::test]
490 #[serial_test::serial]
491 async fn apply_connect_timeout_bounds_an_explicit_builder() {
492 let _guard = InstalledTimeoutsGuard::recording_current();
493 install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
494
495 let client = apply_connect_timeout(Client::builder())
496 .build()
497 .expect("explicit builder builds");
498 let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
499
500 let error = outcome
503 .expect("connect phase must resolve before the outer guard")
504 .expect_err("an unroutable endpoint must not succeed");
505 assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
506 }
507}