Skip to main content

vtcode_commons/
http.rs

1//! HTTP client utilities
2//!
3//! Timeouts here are **defaults, not laws**. A 5-second connect timeout is fine
4//! for a warm cache next to the registry and wrong for a first-ever TLS
5//! handshake over a slow link; a 30-second request timeout is generous for a
6//! metadata fetch and fatal for a cold-start local model. Callers that know
7//! better can pass explicit values, and users can override the defaults through
8//! `[timeouts] http_connect_seconds` / `http_request_seconds` /
9//! `http_download_seconds` (or the `VTCODE_HTTP_*_TIMEOUT_SECS` env vars), which
10//! reach this module via [`install_http_timeouts`].
11
12use reqwest::{Client, ClientBuilder};
13use std::sync::LazyLock;
14use std::time::Duration;
15
16/// Default connect timeout for outbound HTTP clients.
17const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
18/// Default whole-request timeout for outbound HTTP clients.
19const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
20/// Default timeout for large artifact transfers (release downloads, assets).
21const DEFAULT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
22
23/// Resolved HTTP timeout defaults shared by every outbound client.
24///
25/// `None` means "no timeout of this kind" — reqwest then leaves the phase
26/// unbounded, which is the documented meaning of a `0` in `[timeouts]`. Keeping
27/// the distinction in the type (rather than a `Duration::ZERO` sentinel) means
28/// call sites cannot accidentally install a zero deadline that fails instantly.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct HttpTimeouts {
31    /// Timeout for the TCP/TLS connect phase.
32    pub connect: Option<Duration>,
33    /// Timeout for the whole request, including the response body.
34    pub request: Option<Duration>,
35    /// Timeout for large artifact transfers.
36    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    /// Resolve a configured number of `[timeouts]` seconds into an optional
51    /// duration, where `0` disables the phase.
52    #[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    /// Apply the connect and request phases to a builder, skipping disabled
62    /// phases instead of installing a zero deadline.
63    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
74/// `0` means "disabled" throughout `[timeouts]`; keep that convention here so an
75/// explicit `0` never turns into an instant-fail `Duration::ZERO` deadline.
76const 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
87/// Install process-wide HTTP timeout defaults.
88///
89/// Called once at startup from the loaded configuration and again on live
90/// reload, so `[timeouts]` edits apply without a restart. Clients already built
91/// keep the values they were created with; new clients pick these up.
92pub fn install_http_timeouts(timeouts: HttpTimeouts) {
93    HTTP_TIMEOUTS.store(std::sync::Arc::new(timeouts));
94}
95
96/// Current process-wide HTTP timeout defaults.
97#[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        // Avoid system proxy discovery on macOS because it can panic in restricted environments.
106        builder.no_proxy()
107    }
108    #[cfg(not(target_os = "macos"))]
109    {
110        builder
111    }
112}
113
114/// Try to build an HTTP client, returning an error if both primary and fallback builders fail.
115///
116/// This is the fallible version of `build_client`. Use this when you want to handle
117/// the error gracefully (e.g., return an error to the caller) instead of panicking.
118fn 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
142/// Create a default HTTP client with standard timeouts.
143///
144/// Falls back to a bare default client if the configured builder and its
145/// fallback both fail (e.g., TLS misconfiguration, resource exhaustion).
146/// The fallback mirrors the resilience pattern in `http_client_pool`: a
147/// `tracing::error!` is logged so the failure is observable, but the process
148/// does not crash — callers get a working (if unconfigured) client.
149fn 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
163/// Create a default HTTP client with the installed timeout defaults.
164pub fn create_default_client() -> Client {
165    build_client(|builder| http_timeouts().apply_to(builder))
166}
167
168/// Create an HTTP client with a custom request timeout.
169///
170/// The connect phase keeps the installed default so a stalled TCP/TLS handshake
171/// never inherits the (usually much longer) request budget.
172pub 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
183/// Create an HTTP client with custom connect and request timeouts.
184pub 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
188/// Build an origin-bound client without a fallback that could enable redirects.
189pub 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
200/// Build an origin-bound client without redirects, using the installed defaults.
201///
202/// For call sites that must not follow redirects (credentials could otherwise be
203/// replayed to a new origin) but still need a bounded request.
204pub 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
216/// Create an HTTP client with a specific user agent.
217pub 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
225/// Create an HTTP client optimized for streaming.
226///
227/// Streaming intentionally has **no** whole-request timeout: a chat completion
228/// that trickles tokens for longer than the request budget is healthy, not hung.
229/// The connect phase stays bounded, and callers that need a stall detector should
230/// set an idle/`read_timeout` instead of a total deadline.
231pub 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
242/// Create a client for large artifact transfers (release downloads, assets).
243///
244/// Uses the installed `download` budget instead of the request budget: a
245/// multi-megabyte archive on a cold cache routinely needs longer than the
246/// interactive request default, and failing it silently is how installers end up
247/// "hanging for 60 seconds and dying" on slower connections.
248pub 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
262/// Get a default client or create one
263pub fn get_or_create_default_client(existing: Option<Client>) -> Client {
264    existing.unwrap_or_else(create_default_client)
265}
266
267/// Apply the installed connect budget to an explicit client builder.
268///
269/// Prefer the `create_*` constructors; use this only where a call site needs
270/// builder options they do not offer (redirect or retry policy tweaks).
271/// A `None` budget (the phase was disabled) leaves the builder untouched.
272pub 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    /// Restores the process-global defaults on drop so a failing assertion
308    /// cannot leak installed timeouts into sibling tests.
309    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    // These two tests mutate process-global state; `serial` keeps them from
324    // interleaving with each other under nextest's parallel threads.
325    #[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        // Disabled phases must not panic or install a zero deadline.
342        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    /// Reserved, unroutable TEST-NET-2 address (RFC 5737): connection attempts
360    /// stall in (or fail fast from) the handshake without any live server.
361    const UNROUTABLE_URL: &str = "http://198.51.100.1:9/hang";
362
363    /// Outer bound for every stall test: the client budget must fire long
364    /// before this guard, which exists only to fail (never hang) the suite
365    /// if a timeout regresses.
366    const STALL_TEST_OUTER_GUARD: Duration = Duration::from_secs(15);
367
368    /// Bind an ephemeral localhost fixture listener. Returns `None` when the
369    /// sandbox forbids binding (callers skip): mirrors the OAuth
370    /// callback-server tests, which cannot assume socket access either.
371    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    /// Fixture for request-budget tests: a bound listener that is never
380    /// accepted, so the kernel completes the TCP handshake via the backlog
381    /// and the request stalls waiting for a response. The listener must stay
382    /// alive for the whole request.
383    fn stalled_listener() -> Option<std::net::TcpListener> {
384        bind_fixture_listener()
385    }
386
387    /// Serve exactly one minimal `200 OK` on a background thread, then exit.
388    /// The handle is deliberately detached, never joined: joining would hang
389    /// the suite if the client errored before sending.
390    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        // No `is_timeout`/elapsed asserts here: some environments fail fast
435        // with "unreachable" instead of timing out. The guarantee under test
436        // is that the connect phase always resolves bounded with a diagnostic.
437        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        // Same robustness caveat as the unroutable-host test: the guarantee
501        // under test is a bounded connect phase with a diagnostic.
502        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}