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