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
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 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 #[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 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}