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#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn default_timeouts_match_historical_constants() {
273        let defaults = HttpTimeouts::default();
274        assert_eq!(defaults.connect, Some(Duration::from_secs(5)));
275        assert_eq!(defaults.request, Some(Duration::from_secs(30)));
276        assert_eq!(defaults.download, Some(Duration::from_secs(300)));
277    }
278
279    #[test]
280    fn zero_seconds_disables_the_phase() {
281        let timeouts = HttpTimeouts::from_seconds(0, 0, 0);
282        assert_eq!(timeouts.connect, None);
283        assert_eq!(timeouts.request, None);
284        assert_eq!(timeouts.download, None);
285    }
286
287    #[test]
288    fn from_seconds_preserves_explicit_values() {
289        let timeouts = HttpTimeouts::from_seconds(12, 45, 900);
290        assert_eq!(timeouts.connect, Some(Duration::from_secs(12)));
291        assert_eq!(timeouts.request, Some(Duration::from_secs(45)));
292        assert_eq!(timeouts.download, Some(Duration::from_secs(900)));
293    }
294
295    /// Restores the process-global defaults on drop so a failing assertion
296    /// cannot leak installed timeouts into sibling tests.
297    struct InstalledTimeoutsGuard(HttpTimeouts);
298
299    impl InstalledTimeoutsGuard {
300        fn recording_current() -> Self {
301            Self(http_timeouts())
302        }
303    }
304
305    impl Drop for InstalledTimeoutsGuard {
306        fn drop(&mut self) {
307            install_http_timeouts(self.0);
308        }
309    }
310
311    // These two tests mutate process-global state; `serial` keeps them from
312    // interleaving with each other under nextest's parallel threads.
313    #[test]
314    #[serial_test::serial]
315    fn installing_timeouts_is_observable_and_restorable() {
316        let _guard = InstalledTimeoutsGuard::recording_current();
317
318        install_http_timeouts(HttpTimeouts::from_seconds(0, 7, 11));
319        assert_eq!(http_timeouts().connect, None);
320        assert_eq!(http_timeouts().request, Some(Duration::from_secs(7)));
321        assert_eq!(http_timeouts().download, Some(Duration::from_secs(11)));
322    }
323
324    #[test]
325    #[serial_test::serial]
326    fn clients_build_for_every_timeout_shape() {
327        let _guard = InstalledTimeoutsGuard::recording_current();
328
329        // Disabled phases must not panic or install a zero deadline.
330        for timeouts in [
331            HttpTimeouts::default(),
332            HttpTimeouts::from_seconds(0, 0, 0),
333            HttpTimeouts::from_seconds(1, 1, 1),
334        ] {
335            install_http_timeouts(timeouts);
336            drop(create_default_client());
337            drop(create_client_with_timeout(Duration::from_secs(3)));
338            drop(create_streaming_client());
339            drop(create_client_with_user_agent("vtcode-test"));
340            drop(create_download_client("vtcode-test"));
341            let origin_bound =
342                create_default_client_without_redirects().expect("client without redirects should build");
343            drop(origin_bound);
344        }
345    }
346}