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::future::Future;
14use std::sync::LazyLock;
15use std::time::Duration;
16
17use crate::retry::RetryPolicy;
18
19/// Default connect timeout for outbound HTTP clients.
20const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
21/// Default whole-request timeout for outbound HTTP clients.
22const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
23/// Default timeout for large artifact transfers (release downloads, assets).
24const DEFAULT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
25
26/// Resolved HTTP timeout defaults shared by every outbound client.
27///
28/// `None` means "no timeout of this kind" — reqwest then leaves the phase
29/// unbounded, which is the documented meaning of a `0` in `[timeouts]`. Keeping
30/// the distinction in the type (rather than a `Duration::ZERO` sentinel) means
31/// call sites cannot accidentally install a zero deadline that fails instantly.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct HttpTimeouts {
34    /// Timeout for the TCP/TLS connect phase.
35    pub connect: Option<Duration>,
36    /// Timeout for the whole request, including the response body.
37    pub request: Option<Duration>,
38    /// Timeout for large artifact transfers.
39    pub download: Option<Duration>,
40}
41
42impl Default for HttpTimeouts {
43    fn default() -> Self {
44        Self {
45            connect: Some(DEFAULT_CONNECT_TIMEOUT),
46            request: Some(DEFAULT_REQUEST_TIMEOUT),
47            download: Some(DEFAULT_DOWNLOAD_TIMEOUT),
48        }
49    }
50}
51
52impl HttpTimeouts {
53    /// Resolve a configured number of `[timeouts]` seconds into an optional
54    /// duration, where `0` disables the phase.
55    #[must_use]
56    pub const fn from_seconds(connect: u64, request: u64, download: u64) -> Self {
57        Self {
58            connect: duration_or_disabled(connect),
59            request: duration_or_disabled(request),
60            download: duration_or_disabled(download),
61        }
62    }
63
64    /// Apply the connect and request phases to a builder, skipping disabled
65    /// phases instead of installing a zero deadline.
66    fn apply_to(self, mut builder: ClientBuilder) -> ClientBuilder {
67        if let Some(request) = self.request {
68            builder = builder.timeout(request);
69        }
70        if let Some(connect) = self.connect {
71            builder = builder.connect_timeout(connect);
72        }
73        builder
74    }
75}
76
77/// `0` means "disabled" throughout `[timeouts]`; keep that convention here so an
78/// explicit `0` never turns into an instant-fail `Duration::ZERO` deadline.
79const fn duration_or_disabled(seconds: u64) -> Option<Duration> {
80    if seconds == 0 {
81        None
82    } else {
83        Some(Duration::from_secs(seconds))
84    }
85}
86
87static HTTP_TIMEOUTS: LazyLock<arc_swap::ArcSwap<HttpTimeouts>> =
88    LazyLock::new(|| arc_swap::ArcSwap::from_pointee(HttpTimeouts::default()));
89
90/// Install process-wide HTTP timeout defaults.
91///
92/// Called once at startup from the loaded configuration and again on live
93/// reload, so `[timeouts]` edits apply without a restart. Clients already built
94/// keep the values they were created with; new clients pick these up.
95pub fn install_http_timeouts(timeouts: HttpTimeouts) {
96    HTTP_TIMEOUTS.store(std::sync::Arc::new(timeouts));
97}
98
99/// Current process-wide HTTP timeout defaults.
100#[must_use]
101pub fn http_timeouts() -> HttpTimeouts {
102    **HTTP_TIMEOUTS.load()
103}
104
105fn apply_platform_proxy_policy(builder: ClientBuilder) -> ClientBuilder {
106    #[cfg(target_os = "macos")]
107    {
108        // Avoid system proxy discovery on macOS because it can panic in restricted environments.
109        builder.no_proxy()
110    }
111    #[cfg(not(target_os = "macos"))]
112    {
113        builder
114    }
115}
116
117/// Try to build an HTTP client, returning an error if both primary and fallback builders fail.
118///
119/// This is the fallible version of `build_client`. Use this when you want to handle
120/// the error gracefully (e.g., return an error to the caller) instead of panicking.
121fn try_build_client<F>(configure: F) -> Result<Client, reqwest::Error>
122where
123    F: Fn(ClientBuilder) -> ClientBuilder,
124{
125    let primary_builder = configure(apply_platform_proxy_policy(ClientBuilder::new()));
126    match primary_builder.build() {
127        Ok(client) => Ok(client),
128        Err(primary_err) => {
129            let fallback_builder = HttpTimeouts::default().apply_to(apply_platform_proxy_policy(ClientBuilder::new()));
130            match fallback_builder.build() {
131                Ok(client) => Ok(client),
132                Err(fallback_err) => {
133                    tracing::error!(
134                        primary_error = %primary_err,
135                        fallback_error = %fallback_err,
136                        "HTTP client creation failed with both primary and fallback configurations"
137                    );
138                    Err(fallback_err)
139                }
140            }
141        }
142    }
143}
144
145/// Create a default HTTP client with standard timeouts.
146///
147/// Falls back to a bare default client if the configured builder and its
148/// fallback both fail (e.g., TLS misconfiguration, resource exhaustion).
149/// The fallback mirrors the resilience pattern in `http_client_pool`: a
150/// `tracing::error!` is logged so the failure is observable, but the process
151/// does not crash — callers get a working (if unconfigured) client.
152fn build_client<F>(configure: F) -> Client
153where
154    F: Fn(ClientBuilder) -> ClientBuilder,
155{
156    try_build_client(configure).unwrap_or_else(|e| {
157        tracing::error!(
158            error = %e,
159            "HTTP client build failed (primary + fallback); falling back to bare default client. \
160             This usually indicates a TLS configuration issue or system resource exhaustion."
161        );
162        Client::new()
163    })
164}
165
166/// Create a default HTTP client with the installed timeout defaults.
167pub fn create_default_client() -> Client {
168    build_client(|builder| http_timeouts().apply_to(builder))
169}
170
171/// Create an HTTP client with a custom request timeout.
172///
173/// The connect phase keeps the installed default so a stalled TCP/TLS handshake
174/// never inherits the (usually much longer) request budget.
175pub fn create_client_with_timeout(timeout: Duration) -> Client {
176    let connect = http_timeouts().connect;
177    build_client(move |builder| {
178        let builder = builder.timeout(timeout);
179        match connect {
180            Some(connect) => builder.connect_timeout(connect),
181            None => builder,
182        }
183    })
184}
185
186/// Create an HTTP client with custom connect and request timeouts.
187pub fn create_client_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Client {
188    build_client(|builder| builder.timeout(request_timeout).connect_timeout(connect_timeout))
189}
190
191/// Build an origin-bound client without a fallback that could enable redirects.
192pub fn create_client_without_redirects(
193    connect_timeout: Duration,
194    request_timeout: Duration,
195) -> Result<Client, reqwest::Error> {
196    apply_platform_proxy_policy(ClientBuilder::new())
197        .timeout(request_timeout)
198        .connect_timeout(connect_timeout)
199        .redirect(reqwest::redirect::Policy::none())
200        .build()
201}
202
203/// Build an origin-bound client without redirects, using the installed defaults.
204///
205/// For call sites that must not follow redirects (credentials could otherwise be
206/// replayed to a new origin) but still need a bounded request.
207pub fn create_default_client_without_redirects() -> Result<Client, reqwest::Error> {
208    let timeouts = http_timeouts();
209    let mut builder = apply_platform_proxy_policy(ClientBuilder::new()).redirect(reqwest::redirect::Policy::none());
210    if let Some(request) = timeouts.request {
211        builder = builder.timeout(request);
212    }
213    if let Some(connect) = timeouts.connect {
214        builder = builder.connect_timeout(connect);
215    }
216    builder.build()
217}
218
219/// Create an HTTP client with a specific user agent.
220pub fn create_client_with_user_agent(user_agent: &str) -> Client {
221    let timeouts = http_timeouts();
222    build_client(move |mut builder| {
223        builder = builder.user_agent(user_agent);
224        timeouts.apply_to(builder)
225    })
226}
227
228/// Create an HTTP client optimized for streaming.
229///
230/// Streaming intentionally has **no** whole-request timeout: a chat completion
231/// that trickles tokens for longer than the request budget is healthy, not hung.
232/// The connect phase stays bounded, and callers that need a stall detector should
233/// set an idle/`read_timeout` instead of a total deadline.
234pub fn create_streaming_client() -> Client {
235    let connect = http_timeouts().connect;
236    build_client(move |builder| {
237        let builder = builder.tcp_keepalive(Some(Duration::from_secs(60)));
238        match connect {
239            Some(connect) => builder.connect_timeout(connect),
240            None => builder,
241        }
242    })
243}
244
245/// Create a client for large artifact transfers (release downloads, assets).
246///
247/// Uses the installed `download` budget instead of the request budget: a
248/// multi-megabyte archive on a cold cache routinely needs longer than the
249/// interactive request default, and failing it silently is how installers end up
250/// "hanging for 60 seconds and dying" on slower connections.
251pub fn create_download_client(user_agent: &str) -> Client {
252    let timeouts = http_timeouts();
253    build_client(move |mut builder| {
254        builder = builder.user_agent(user_agent).tcp_keepalive(Some(Duration::from_secs(60)));
255        if let Some(download) = timeouts.download {
256            builder = builder.timeout(download);
257        }
258        if let Some(connect) = timeouts.connect {
259            builder = builder.connect_timeout(connect);
260        }
261        builder
262    })
263}
264
265/// Get a default client or create one
266pub fn get_or_create_default_client(existing: Option<Client>) -> Client {
267    existing.unwrap_or_else(create_default_client)
268}
269
270/// Apply the installed connect budget to an explicit client builder.
271///
272/// Prefer the `create_*` constructors; use this only where a call site needs
273/// builder options they do not offer (redirect or retry policy tweaks).
274/// A `None` budget (the phase was disabled) leaves the builder untouched.
275pub fn apply_connect_timeout(builder: ClientBuilder) -> ClientBuilder {
276    match http_timeouts().connect {
277        Some(connect) => builder.connect_timeout(connect),
278        None => builder,
279    }
280}
281
282/// Send one request per attempt with capped exponential backoff and jitter.
283///
284/// The caller rebuilds the request on every attempt via `make_request` (a
285/// `RequestBuilder` is consumed by `send()`, so it cannot be reused).
286/// Transport failures (connect errors, timeouts) are retried; builder errors
287/// fail fast because retrying a malformed request can never succeed.
288/// Responses with a retryable status (429/500/502/503/504 per
289/// [`RetryPolicy::classify_status`]) are retried while attempts remain;
290/// otherwise the final response is returned for the caller to handle.
291///
292/// This is the shared helper for idempotent background fetches (release
293/// metadata, asset downloads, catalog refreshes). Interactive LLM requests
294/// must NOT use it: the runloop already retries those under its own budget,
295/// and stacking a transport retry underneath would multiply dispatches and
296/// provider billing.
297///
298/// What this does NOT do: it never honors `Retry-After` (policy backoff
299/// only; `Retry-After` cooperation stays at the tool-registry layer where the
300/// server relationship exists), and it never retries a response body failure —
301/// only the initial `send()`.
302pub async fn send_with_retry<F, Fut>(policy: &RetryPolicy, make_request: F) -> Result<reqwest::Response, reqwest::Error>
303where
304    F: Fn() -> Fut,
305    Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
306{
307    send_with_retry_inner(policy, true, make_request).await
308}
309
310/// Retry only when no response was received (transport failure), never on an
311/// HTTP status. For non-idempotent OAuth token POSTs: an authorization code
312/// may already be consumed when a 5xx arrives, so a status-triggered retry
313/// would just burn the login; a transport error means the server likely never
314/// saw the request, making one more attempt strictly better than failing.
315pub async fn send_with_retry_transport_only<F, Fut>(
316    policy: &RetryPolicy,
317    make_request: F,
318) -> Result<reqwest::Response, reqwest::Error>
319where
320    F: Fn() -> Fut,
321    Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
322{
323    send_with_retry_inner(policy, false, make_request).await
324}
325
326async fn send_with_retry_inner<F, Fut>(
327    policy: &RetryPolicy,
328    retry_status: bool,
329    make_request: F,
330) -> Result<reqwest::Response, reqwest::Error>
331where
332    F: Fn() -> Fut,
333    Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
334{
335    // Termination variant: `attempt` rises toward `attempts`, and the `last`
336    // arm returns on the final iteration, so the loop always exits.
337    let attempts = policy.max_attempts.max(1);
338    let mut attempt: u32 = 0;
339    loop {
340        if attempt > 0 {
341            tokio::time::sleep(policy.delay_for_attempt(attempt - 1)).await;
342        }
343        let last = attempt + 1 >= attempts;
344        match make_request().await {
345            Ok(response) if !retry_status || last || !policy.classify_status(response.status().as_u16()).retryable => {
346                return Ok(response);
347            }
348            Ok(_) => {}
349            Err(error) if error.is_builder() || last => return Err(error),
350            Err(_) => {}
351        }
352        attempt += 1;
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn default_timeouts_match_historical_constants() {
362        let defaults = HttpTimeouts::default();
363        assert_eq!(defaults.connect, Some(Duration::from_secs(5)));
364        assert_eq!(defaults.request, Some(Duration::from_secs(30)));
365        assert_eq!(defaults.download, Some(Duration::from_secs(300)));
366    }
367
368    #[test]
369    fn zero_seconds_disables_the_phase() {
370        let timeouts = HttpTimeouts::from_seconds(0, 0, 0);
371        assert_eq!(timeouts.connect, None);
372        assert_eq!(timeouts.request, None);
373        assert_eq!(timeouts.download, None);
374    }
375
376    #[test]
377    fn from_seconds_preserves_explicit_values() {
378        let timeouts = HttpTimeouts::from_seconds(12, 45, 900);
379        assert_eq!(timeouts.connect, Some(Duration::from_secs(12)));
380        assert_eq!(timeouts.request, Some(Duration::from_secs(45)));
381        assert_eq!(timeouts.download, Some(Duration::from_secs(900)));
382    }
383
384    /// Restores the process-global defaults on drop so a failing assertion
385    /// cannot leak installed timeouts into sibling tests.
386    struct InstalledTimeoutsGuard(HttpTimeouts);
387
388    impl InstalledTimeoutsGuard {
389        fn recording_current() -> Self {
390            Self(http_timeouts())
391        }
392    }
393
394    impl Drop for InstalledTimeoutsGuard {
395        fn drop(&mut self) {
396            install_http_timeouts(self.0);
397        }
398    }
399
400    // These two tests mutate process-global state; `serial` keeps them from
401    // interleaving with each other under nextest's parallel threads.
402    #[test]
403    #[serial_test::serial]
404    fn installing_timeouts_is_observable_and_restorable() {
405        let _guard = InstalledTimeoutsGuard::recording_current();
406
407        install_http_timeouts(HttpTimeouts::from_seconds(0, 7, 11));
408        assert_eq!(http_timeouts().connect, None);
409        assert_eq!(http_timeouts().request, Some(Duration::from_secs(7)));
410        assert_eq!(http_timeouts().download, Some(Duration::from_secs(11)));
411    }
412
413    #[test]
414    #[serial_test::serial]
415    fn clients_build_for_every_timeout_shape() {
416        let _guard = InstalledTimeoutsGuard::recording_current();
417
418        // Disabled phases must not panic or install a zero deadline.
419        for timeouts in [
420            HttpTimeouts::default(),
421            HttpTimeouts::from_seconds(0, 0, 0),
422            HttpTimeouts::from_seconds(1, 1, 1),
423        ] {
424            install_http_timeouts(timeouts);
425            drop(create_default_client());
426            drop(create_client_with_timeout(Duration::from_secs(3)));
427            drop(create_streaming_client());
428            drop(create_client_with_user_agent("vtcode-test"));
429            drop(create_download_client("vtcode-test"));
430            let origin_bound =
431                create_default_client_without_redirects().expect("client without redirects should build");
432            drop(origin_bound);
433        }
434    }
435
436    /// Reserved, unroutable TEST-NET-2 address (RFC 5737): connection attempts
437    /// stall in (or fail fast from) the handshake without any live server.
438    const UNROUTABLE_URL: &str = "http://198.51.100.1:9/hang";
439
440    /// Outer bound for every stall test: the client budget must fire long
441    /// before this guard, which exists only to fail (never hang) the suite
442    /// if a timeout regresses.
443    const STALL_TEST_OUTER_GUARD: Duration = Duration::from_secs(15);
444
445    /// Bind an ephemeral localhost fixture listener. Returns `None` when the
446    /// sandbox forbids binding (callers skip): mirrors the OAuth
447    /// callback-server tests, which cannot assume socket access either.
448    fn bind_fixture_listener() -> Option<std::net::TcpListener> {
449        match std::net::TcpListener::bind("127.0.0.1:0") {
450            Ok(listener) => Some(listener),
451            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
452            Err(err) => panic!("bind fixture listener: {err}"),
453        }
454    }
455
456    /// Fixture for request-budget tests: a bound listener that is never
457    /// accepted, so the kernel completes the TCP handshake via the backlog
458    /// and the request stalls waiting for a response. The listener must stay
459    /// alive for the whole request.
460    fn stalled_listener() -> Option<std::net::TcpListener> {
461        bind_fixture_listener()
462    }
463
464    /// Serve exactly one minimal `200 OK` on a background thread, then exit.
465    /// The handle is deliberately detached, never joined: joining would hang
466    /// the suite if the client errored before sending.
467    fn respond_once(listener: std::net::TcpListener) {
468        drop(std::thread::spawn(move || {
469            let (mut stream, _) = listener.accept().expect("accept test connection");
470            use std::io::{Read, Write};
471            let mut request = [0u8; 4096];
472            let request_bytes = stream.read(&mut request).expect("read test request");
473            assert!(request_bytes > 0, "client must send the request");
474            stream
475                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
476                .expect("write test response");
477        }));
478    }
479
480    #[tokio::test]
481    #[serial_test::serial]
482    async fn default_client_request_budget_bounds_an_accept_then_stall_server() {
483        let _guard = InstalledTimeoutsGuard::recording_current();
484        install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
485
486        let Some(listener) = stalled_listener() else { return };
487        let addr = listener.local_addr().expect("listener address");
488
489        let client = create_default_client();
490        let started = std::time::Instant::now();
491        let outcome =
492            tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
493        let elapsed = started.elapsed();
494
495        let error = outcome
496            .expect("request budget must fire before the outer guard")
497            .expect_err("a stalled server must not succeed");
498        assert!(error.is_timeout(), "expected a timeout error, got: {error}");
499        assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
500    }
501
502    #[tokio::test]
503    #[serial_test::serial]
504    async fn default_client_connect_phase_cannot_hang_on_unroutable_host() {
505        let _guard = InstalledTimeoutsGuard::recording_current();
506        install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
507
508        let client = create_default_client();
509        let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
510
511        // No `is_timeout`/elapsed asserts here: some environments fail fast
512        // with "unreachable" instead of timing out. The guarantee under test
513        // is that the connect phase always resolves bounded with a diagnostic.
514        let error = outcome
515            .expect("connect phase must resolve before the outer guard")
516            .expect_err("an unroutable endpoint must not succeed");
517        assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
518    }
519
520    #[tokio::test]
521    #[serial_test::serial]
522    async fn origin_bound_client_request_budget_bounds_a_stalled_server() {
523        let _guard = InstalledTimeoutsGuard::recording_current();
524        install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
525
526        let Some(listener) = stalled_listener() else { return };
527        let addr = listener.local_addr().expect("listener address");
528
529        let client = create_default_client_without_redirects().expect("origin-bound client builds");
530        let started = std::time::Instant::now();
531        let outcome =
532            tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
533        let elapsed = started.elapsed();
534
535        let error = outcome
536            .expect("request budget must fire before the outer guard")
537            .expect_err("a stalled server must not succeed");
538        assert!(error.is_timeout(), "expected a timeout error, got: {error}");
539        assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
540    }
541
542    #[tokio::test]
543    #[serial_test::serial]
544    async fn bounded_clients_still_succeed_against_a_healthy_server() {
545        let _guard = InstalledTimeoutsGuard::recording_current();
546        install_http_timeouts(HttpTimeouts::from_seconds(5, 5, 300));
547
548        let Some(listener) = bind_fixture_listener() else {
549            return;
550        };
551        let addr = listener.local_addr().expect("listener address");
552        respond_once(listener);
553
554        let outcome =
555            tokio::time::timeout(STALL_TEST_OUTER_GUARD, create_default_client().get(format!("http://{addr}/")).send())
556                .await;
557        let body = outcome
558            .expect("healthy server must answer before the outer guard")
559            .expect("bounded client must succeed")
560            .text()
561            .await
562            .expect("read healthy body");
563        assert_eq!(body, "ok");
564    }
565
566    #[tokio::test]
567    #[serial_test::serial]
568    async fn apply_connect_timeout_bounds_an_explicit_builder() {
569        let _guard = InstalledTimeoutsGuard::recording_current();
570        install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
571
572        let client = apply_connect_timeout(Client::builder())
573            .build()
574            .expect("explicit builder builds");
575        let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
576
577        // Same robustness caveat as the unroutable-host test: the guarantee
578        // under test is a bounded connect phase with a diagnostic.
579        let error = outcome
580            .expect("connect phase must resolve before the outer guard")
581            .expect_err("an unroutable endpoint must not succeed");
582        assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
583    }
584
585    /// Serve a scripted sequence of statuses on a background thread, one per
586    /// connection. Detached like [`respond_once`]: never joined.
587    fn respond_scripted(listener: std::net::TcpListener, statuses: Vec<u16>) {
588        drop(std::thread::spawn(move || {
589            use std::io::{Read, Write};
590            for status in statuses {
591                let Ok((mut stream, _)) = listener.accept() else {
592                    break;
593                };
594                let mut request = [0u8; 4096];
595                if stream.read(&mut request).is_err() {
596                    break;
597                }
598                let response = format!("HTTP/1.1 {status} Test\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
599                if stream.write_all(response.as_bytes()).is_err() {
600                    break;
601                }
602            }
603        }));
604    }
605
606    /// Reserve an ephemeral port and release it, so connections are refused
607    /// fast. Returns `None` when the sandbox forbids binding (callers skip).
608    fn reserve_refused_port() -> Option<u16> {
609        match std::net::TcpListener::bind("127.0.0.1:0") {
610            Ok(listener) => Some(listener.local_addr().expect("listener address").port()),
611            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
612            Err(err) => panic!("reserve refused port: {err}"),
613        }
614    }
615
616    fn retry_test_policy() -> RetryPolicy {
617        RetryPolicy::from_retries(2, Duration::from_millis(1), Duration::from_millis(5), 2.0)
618            .with_jitter(RetryPolicy::DEFAULT_JITTER)
619    }
620
621    fn retry_test_client() -> Client {
622        create_client_with_timeouts(Duration::from_secs(5), Duration::from_secs(5))
623    }
624
625    #[tokio::test]
626    async fn retryable_status_is_retried_until_success() {
627        let Some(listener) = bind_fixture_listener() else {
628            return;
629        };
630        let addr = listener.local_addr().expect("listener address");
631        respond_scripted(listener, vec![503, 429, 200]);
632
633        let client = retry_test_client();
634        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
635        let policy = retry_test_policy();
636        let outcome = tokio::time::timeout(
637            STALL_TEST_OUTER_GUARD,
638            send_with_retry(&policy, || {
639                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
640                client.get(format!("http://{addr}/")).send()
641            }),
642        )
643        .await
644        .expect("scripted server must answer before the outer guard")
645        .expect("503,429,200 must recover");
646
647        assert_eq!(outcome.status().as_u16(), 200);
648        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
649    }
650
651    #[tokio::test]
652    async fn exhausted_status_budget_returns_the_final_response() {
653        let Some(listener) = bind_fixture_listener() else {
654            return;
655        };
656        let addr = listener.local_addr().expect("listener address");
657        respond_scripted(listener, vec![503, 503, 503, 503]);
658
659        let client = retry_test_client();
660        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
661        let policy = retry_test_policy();
662        let outcome = tokio::time::timeout(
663            STALL_TEST_OUTER_GUARD,
664            send_with_retry(&policy, || {
665                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
666                client.get(format!("http://{addr}/")).send()
667            }),
668        )
669        .await
670        .expect("scripted server must answer before the outer guard")
671        .expect("exhaustion returns the response, not an error");
672
673        // The helper reports; the caller decides: a terminal 503 arrives as a
674        // response for `error_for_status` handling, not a transport error.
675        assert_eq!(outcome.status().as_u16(), 503);
676        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
677    }
678
679    #[tokio::test]
680    async fn non_retryable_status_returns_immediately() {
681        let Some(listener) = bind_fixture_listener() else {
682            return;
683        };
684        let addr = listener.local_addr().expect("listener address");
685        respond_scripted(listener, vec![404, 200]);
686
687        let client = retry_test_client();
688        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
689        let policy = retry_test_policy();
690        let outcome = tokio::time::timeout(
691            STALL_TEST_OUTER_GUARD,
692            send_with_retry(&policy, || {
693                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
694                client.get(format!("http://{addr}/")).send()
695            }),
696        )
697        .await
698        .expect("scripted server must answer before the outer guard")
699        .expect("404 must arrive as a response");
700
701        // Asymmetric partner to the 503/429 recovery: 404 never retries, so the
702        // scripted 200 is never consumed.
703        assert_eq!(outcome.status().as_u16(), 404);
704        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
705    }
706
707    #[tokio::test]
708    async fn transport_failure_is_retried_then_returned() {
709        let Some(port) = reserve_refused_port() else {
710            return;
711        };
712
713        let client = retry_test_client();
714        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
715        let policy = retry_test_policy();
716        let outcome = tokio::time::timeout(
717            STALL_TEST_OUTER_GUARD,
718            send_with_retry(&policy, || {
719                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
720                client.get(format!("http://127.0.0.1:{port}/")).send()
721            }),
722        )
723        .await
724        .expect("refused connections must resolve before the outer guard");
725
726        assert!(outcome.is_err(), "a refused port must not succeed");
727        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
728    }
729
730    #[tokio::test]
731    async fn builder_error_fails_fast_without_retry() {
732        let client = retry_test_client();
733        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
734        let policy = retry_test_policy();
735        let outcome = send_with_retry(&policy, || {
736            let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
737            client.get("http://exa mple.invalid/").send()
738        })
739        .await;
740
741        // A malformed request can never succeed on retry: one attempt, and the
742        // error must be recognizable as a construction failure.
743        let error = outcome.expect_err("a malformed URL must not succeed");
744        assert!(error.is_builder(), "expected a builder error, got: {error}");
745        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
746    }
747
748    #[tokio::test]
749    async fn transport_only_helper_ignores_retryable_status() {
750        let Some(listener) = bind_fixture_listener() else {
751            return;
752        };
753        let addr = listener.local_addr().expect("listener address");
754        respond_scripted(listener, vec![503, 200]);
755
756        let client = retry_test_client();
757        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
758        let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
759            .with_jitter(RetryPolicy::DEFAULT_JITTER);
760        let outcome = tokio::time::timeout(
761            STALL_TEST_OUTER_GUARD,
762            send_with_retry_transport_only(&policy, || {
763                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
764                client.get(format!("http://{addr}/")).send()
765            }),
766        )
767        .await
768        .expect("scripted server must answer before the outer guard")
769        .expect("503 must arrive as a response");
770
771        assert_eq!(outcome.status().as_u16(), 503);
772        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
773    }
774
775    #[tokio::test]
776    async fn transport_only_helper_still_retries_transport_failure() {
777        let Some(port) = reserve_refused_port() else {
778            return;
779        };
780
781        let client = retry_test_client();
782        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
783        let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
784            .with_jitter(RetryPolicy::DEFAULT_JITTER);
785        let outcome = tokio::time::timeout(
786            STALL_TEST_OUTER_GUARD,
787            send_with_retry_transport_only(&policy, || {
788                let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
789                client.get(format!("http://127.0.0.1:{port}/")).send()
790            }),
791        )
792        .await
793        .expect("refused connections must resolve before the outer guard");
794
795        assert!(outcome.is_err(), "a refused port must not succeed");
796        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
797    }
798}