Skip to main content

lc_core/
http.rs

1//! Unified outbound HTTP client.
2//!
3//! Every provider/API call in the workspace should eventually go through
4//! [`HttpClient`] so that timeout, retry, body-size and cancellation policies
5//! are defined in one place instead of drifting across 20 crates.
6//!
7//! # Policies
8//!
9//! - Two profiles: [`Profile::Api`] (connect 10s, total 60s, body capped at
10//!   10 MiB) and [`Profile::Sse`] (connect 10s, total 300s, response body is
11//!   streamed, not buffered).
12//! - Retries are attempted **only** for statuses `408/429/500/502/503/504` and
13//!   for connection/timeout transport errors. `501` and `505` are returned
14//!   immediately — retrying a "not implemented"/"version unsupported" response
15//!   just hammers a server that can never succeed.
16//! - `Retry-After` is honored in both its forms (delta-seconds **and**
17//!   IMF-fixdate, per RFC 9110) and is never truncated by the backoff cap.
18//! - Backoff uses full jitter; retry sleeps are cancellation-aware.
19//! - For SSE, retries happen only while establishing the stream (before the
20//!   first successful response). A stream that breaks mid-flight is **not**
21//!   reconnected, because that would silently replay already-delivered events.
22//!
23//! # SSRF
24//!
25//! This client is for public provider endpoints and does **not** perform
26//! private-address pinning. Callers that fetch user-supplied URLs (tools, web
27//! loaders) must keep using [`crate::ssrf::guarded_get`] and friends.
28
29use std::io;
30use std::pin::Pin;
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use futures_util::{Stream, StreamExt};
34use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
35use reqwest::{Method, StatusCode};
36use serde_json::Value;
37
38use crate::runnables::CancellationToken;
39use crate::ssrf::read_body_bounded;
40
41/// Default TCP connect timeout for both profiles.
42pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
43/// Default total (connect + send + response headers) budget for API calls.
44pub const DEFAULT_API_TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
45/// Default response body cap for API calls: 10 MiB.
46pub const DEFAULT_API_MAX_BYTES: usize = 10 * 1024 * 1024;
47/// Default total budget for establishing an SSE stream.
48pub const DEFAULT_SSE_TOTAL_TIMEOUT: Duration = Duration::from_secs(300);
49/// Upper bound read from an error response body so messages stay bounded.
50pub const ERROR_BODY_BYTES: usize = 64 * 1024;
51
52/// Errors produced by the unified HTTP layer.
53#[derive(Debug, thiserror::Error)]
54#[non_exhaustive]
55pub enum HttpError {
56    /// The underlying reqwest client could not be constructed.
57    #[error("failed to build HTTP client: {0}")]
58    Build(String),
59    /// A URL failed validation.
60    #[error("invalid URL {url:?}: {reason}")]
61    InvalidUrl {
62        /// The rejected URL.
63        url: String,
64        /// Why it was rejected.
65        reason: String,
66    },
67    /// A transport-level failure (DNS, connection, body read, ...).
68    #[error("HTTP transport error: {0}")]
69    Transport(String),
70    /// The total timeout elapsed before a response/body was produced.
71    #[error("HTTP operation timed out after {0:?}")]
72    Timeout(Duration),
73    /// The operation was cancelled via a [`CancellationToken`].
74    #[error("HTTP operation cancelled")]
75    Cancelled,
76    /// The response body exceeded the configured byte limit.
77    #[error("response body exceeded the {limit} byte limit")]
78    BodyTooLarge {
79        /// The configured limit.
80        limit: usize,
81    },
82    /// A non-success HTTP status. Error bodies are read with a small bound.
83    #[error("HTTP status {status}: {body}")]
84    Status {
85        /// Numeric status code.
86        status: u16,
87        /// Bounded response body.
88        body: String,
89    },
90}
91
92/// Which transport errors are eligible for retry.
93///
94/// A request whose body may already have been dispatched to the server can be
95/// ambiguous: retrying may double-charge or duplicate a side effect. This
96/// mirrors the A14 boundary documented in lc-providers.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum TransportRetryMode {
99    /// Retry on connect, timeout and generic request errors. Better
100    /// availability; carries a small double-dispatch risk.
101    AllTransportErrors,
102    /// Retry only on errors known to have happened before the request was
103    /// dispatched (connect failures). Safe for non-idempotent calls.
104    PreDispatchOnly,
105}
106
107/// Retry policy: attempt count, backoff bounds and transport classification.
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct RetryPolicy {
110    /// Total attempts including the first one (1 = no retries).
111    pub max_attempts: usize,
112    /// Base delay for exponential backoff.
113    pub base_delay: Duration,
114    /// Cap on *computed* backoff; a server-specified `Retry-After` is not
115    /// truncated by this value.
116    pub max_delay: Duration,
117    /// Which transport errors are retriable.
118    pub transport: TransportRetryMode,
119}
120
121impl Default for RetryPolicy {
122    fn default() -> Self {
123        Self {
124            max_attempts: 3,
125            base_delay: Duration::from_millis(500),
126            max_delay: Duration::from_secs(30),
127            transport: TransportRetryMode::AllTransportErrors,
128        }
129    }
130}
131
132impl RetryPolicy {
133    /// A policy that never retries.
134    pub fn none() -> Self {
135        Self {
136            max_attempts: 1,
137            ..Default::default()
138        }
139    }
140}
141
142/// Selects the built-in timeout/body profile.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Profile {
145    /// Buffered JSON API call: bounded total time and bounded response body.
146    Api,
147    /// Streaming response: longer establishment budget, body never buffered.
148    Sse,
149}
150
151/// Per-request overrides applied on top of the client defaults.
152#[derive(Debug, Clone, Default)]
153pub struct RequestOptions {
154    bearer: Option<String>,
155    headers: HeaderMap,
156    /// Per-request override of the retry-transport classification. When
157    /// `None` (the default), non-idempotent methods (POST/...) are retried
158    /// only on pre-dispatch (connect) failures.
159    retry_mode: Option<TransportRetryMode>,
160}
161
162impl RequestOptions {
163    /// Creates empty options.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Sends `Authorization: Bearer <token>` for this request only.
169    pub fn bearer(mut self, token: impl Into<String>) -> Self {
170        self.bearer = Some(token.into());
171        self
172    }
173
174    /// Adds one header for this request only. Replaces a client default
175    /// header with the same name.
176    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
177        self.headers.insert(name, value);
178        self
179    }
180
181    /// Overrides which transport errors make THIS request retry.
182    ///
183    /// By default POST (and other non-idempotent methods) use
184    /// [`TransportRetryMode::PreDispatchOnly`] so a request already
185    /// dispatched to the server cannot be duplicated by a retry; safe
186    /// methods (GET/HEAD) follow the client policy. Set
187    /// [`TransportRetryMode::AllTransportErrors`] here only when the call is
188    /// idempotent or deduplicated server side.
189    pub fn retry_mode(mut self, mode: TransportRetryMode) -> Self {
190        self.retry_mode = Some(mode);
191        self
192    }
193}
194
195/// Builder for [`HttpClient`]. Construct via [`HttpClient::api`] /
196/// [`HttpClient::sse`] or [`HttpClient::builder`].
197#[derive(Debug, Clone)]
198pub struct HttpClientBuilder {
199    profile: Profile,
200    connect_timeout: Duration,
201    total_timeout: Duration,
202    max_bytes: usize,
203    retry: RetryPolicy,
204    bearer: Option<String>,
205    user_agent: Option<String>,
206    default_headers: HeaderMap,
207    use_system_proxy: bool,
208    cancel: Option<CancellationToken>,
209}
210
211impl HttpClientBuilder {
212    fn new(profile: Profile) -> Self {
213        let (total_timeout, max_bytes) = match profile {
214            Profile::Api => (DEFAULT_API_TOTAL_TIMEOUT, DEFAULT_API_MAX_BYTES),
215            Profile::Sse => (DEFAULT_SSE_TOTAL_TIMEOUT, usize::MAX),
216        };
217        Self {
218            profile,
219            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
220            total_timeout,
221            max_bytes,
222            retry: RetryPolicy::default(),
223            bearer: None,
224            user_agent: None,
225            default_headers: HeaderMap::new(),
226            // Default to no_proxy(): local proxy software (Clash, corporate
227            // proxies) otherwise intercepts loopback/LAN base URLs. This was
228            // a production incident (T4).
229            use_system_proxy: false,
230            cancel: None,
231        }
232    }
233
234    /// Overrides connect and total timeouts.
235    pub fn timeouts(mut self, connect: Duration, total: Duration) -> Self {
236        self.connect_timeout = connect;
237        self.total_timeout = total;
238        self
239    }
240
241    /// Overrides the response body byte cap (Api profile only).
242    pub fn max_bytes(mut self, max_bytes: usize) -> Self {
243        self.max_bytes = max_bytes;
244        self
245    }
246
247    /// Sets the retry policy.
248    pub fn retry(mut self, policy: RetryPolicy) -> Self {
249        self.retry = policy;
250        self
251    }
252
253    /// Sets a default bearer token for every request.
254    pub fn bearer(mut self, token: impl Into<String>) -> Self {
255        self.bearer = Some(token.into());
256        self
257    }
258
259    /// Sets the `User-Agent` header.
260    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
261        self.user_agent = Some(user_agent.into());
262        self
263    }
264
265    /// Adds a default header sent on every request.
266    pub fn default_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
267        self.default_headers.insert(name, value);
268        self
269    }
270
271    /// Honors system proxy environment variables (default: ignores them).
272    pub fn use_system_proxy(mut self) -> Self {
273        self.use_system_proxy = true;
274        self
275    }
276
277    /// Attaches a cancellation token observed during retries.
278    pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
279        self.cancel = Some(token);
280        self
281    }
282
283    /// Builds the client.
284    pub fn build(self) -> Result<HttpClient, HttpError> {
285        let mut builder = reqwest::Client::builder()
286            .connect_timeout(self.connect_timeout)
287            // No reqwest-level timeout: the client enforces a total deadline
288            // that spans retries; an SSE stream must not be cut mid-flight.
289            .redirect(reqwest::redirect::Policy::none());
290        if !self.use_system_proxy {
291            builder = builder.no_proxy();
292        }
293        if let Some(ua) = &self.user_agent {
294            builder = builder.user_agent(ua);
295        }
296        let client = builder
297            .build()
298            .map_err(|e| HttpError::Build(e.to_string()))?;
299
300        Ok(HttpClient {
301            client,
302            profile: self.profile,
303            total_timeout: self.total_timeout,
304            max_bytes: self.max_bytes,
305            retry: self.retry,
306            bearer: self.bearer,
307            default_headers: self.default_headers,
308            cancel: self.cancel,
309        })
310    }
311}
312
313/// A configured outbound HTTP client. Cheap to clone (wraps an Arc'd pool).
314#[derive(Debug, Clone)]
315pub struct HttpClient {
316    client: reqwest::Client,
317    profile: Profile,
318    total_timeout: Duration,
319    max_bytes: usize,
320    retry: RetryPolicy,
321    bearer: Option<String>,
322    default_headers: HeaderMap,
323    cancel: Option<CancellationToken>,
324}
325
326impl HttpClient {
327    /// Builder for an API-style buffered client.
328    pub fn api() -> HttpClientBuilder {
329        HttpClientBuilder::new(Profile::Api)
330    }
331
332    /// Builder for an SSE streaming client.
333    pub fn sse() -> HttpClientBuilder {
334        HttpClientBuilder::new(Profile::Sse)
335    }
336
337    /// Builder for an explicit profile.
338    pub fn builder(profile: Profile) -> HttpClientBuilder {
339        HttpClientBuilder::new(profile)
340    }
341
342    /// The profile this client was built with.
343    pub fn profile(&self) -> Profile {
344        self.profile
345    }
346
347    /// GET returning a buffered, size-bounded response (error statuses are
348    /// returned as [`HttpError::Status`]).
349    pub async fn get(&self, url: &str) -> Result<BoundedResponse, HttpError> {
350        self.get_with(url, RequestOptions::new()).await
351    }
352
353    /// GET with per-request options.
354    pub async fn get_with(
355        &self,
356        url: &str,
357        opts: RequestOptions,
358    ) -> Result<BoundedResponse, HttpError> {
359        let (resp, deadline) = self.execute(Method::GET, url, None, &opts).await?;
360        self.read_bounded_within(resp, deadline).await
361    }
362
363    /// POST a JSON body returning a buffered, size-bounded response.
364    pub async fn post_json(&self, url: &str, body: &Value) -> Result<BoundedResponse, HttpError> {
365        self.post_json_with(url, body, RequestOptions::new()).await
366    }
367
368    /// POST a JSON body with per-request options.
369    pub async fn post_json_with(
370        &self,
371        url: &str,
372        body: &Value,
373        opts: RequestOptions,
374    ) -> Result<BoundedResponse, HttpError> {
375        let (resp, deadline) = self.execute(Method::POST, url, Some(body), &opts).await?;
376        self.read_bounded_within(resp, deadline).await
377    }
378
379    /// POSTs a `multipart/form-data` body with per-request options.
380    ///
381    /// A multipart form cannot be replayed (parts may stream from non-cloneable
382    /// sources), so this performs exactly ONE send attempt: no retries, not
383    /// even on a connect failure or retriable status. The total deadline,
384    /// cancellation token and response size bound still apply. The response
385    /// body is buffered like [`HttpClient::post_json_with`].
386    pub async fn post_multipart_with(
387        &self,
388        url: &str,
389        form: reqwest::multipart::Form,
390        opts: RequestOptions,
391    ) -> Result<BoundedResponse, HttpError> {
392        if self
393            .cancel
394            .as_ref()
395            .is_some_and(CancellationToken::is_cancelled)
396        {
397            return Err(HttpError::Cancelled);
398        }
399        let deadline = tokio::time::Instant::now() + self.total_timeout;
400        let request = self
401            .client
402            .request(Method::POST, url)
403            .multipart(form)
404            .headers(self.compose_headers(&opts));
405        let remaining = deadline
406            .checked_duration_since(tokio::time::Instant::now())
407            .ok_or(HttpError::Timeout(self.total_timeout))?;
408        let resp = match tokio::time::timeout(remaining, request.send()).await {
409            Err(_) => return Err(HttpError::Timeout(self.total_timeout)),
410            Ok(Ok(resp)) => resp,
411            Ok(Err(err)) => {
412                if err.is_timeout() {
413                    return Err(HttpError::Timeout(self.total_timeout));
414                }
415                return Err(HttpError::Transport(err.to_string()));
416            }
417        };
418        self.read_bounded_within(resp, deadline).await
419    }
420
421    /// Opens an SSE byte stream. Retries cover only stream establishment;
422    /// once a successful response header set is returned the stream runs to
423    /// completion without reconnecting.
424    ///
425    /// Named `open_sse` (not `sse`) because [`HttpClient::sse`] is the
426    /// SSE-profile builder constructor.
427    pub async fn open_sse(
428        &self,
429        url: &str,
430        body: Option<&Value>,
431        opts: RequestOptions,
432    ) -> Result<SseStream, HttpError> {
433        let method = if body.is_some() {
434            Method::POST
435        } else {
436            Method::GET
437        };
438        let (resp, deadline) = self.execute(method, url, body, &opts).await?;
439        let status = resp.status();
440        if !status.is_success() {
441            // Bound the error-body read with the same deadline `execute` used
442            // for the request: `read_body_bounded` is only size-capped, so a
443            // server that answers e.g. `500` with a small body it never closes
444            // would otherwise hang this caller forever (the buffered Api path
445            // fixes this via `read_bounded_within`; the SSE path must too).
446            let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
447                Some(rem) => rem,
448                None => return Err(HttpError::Timeout(self.total_timeout)),
449            };
450            let (text, truncated) =
451                match tokio::time::timeout(remaining, read_body_bounded(resp, ERROR_BODY_BYTES))
452                    .await
453                {
454                    Ok(Ok(pair)) => pair,
455                    Ok(Err(e)) => {
456                        return Err(HttpError::Transport(format!("read error body: {e}")));
457                    }
458                    Err(_elapsed) => return Err(HttpError::Timeout(self.total_timeout)),
459                };
460            let body = if truncated {
461                truncate_for_error(&text)
462            } else {
463                text
464            };
465            return Err(HttpError::Status {
466                status: status.as_u16(),
467                body,
468            });
469        }
470        let stream = resp
471            .bytes_stream()
472            .map(|result| result.map_err(|e| io::Error::other(e.to_string())));
473        Ok(Box::pin(stream))
474    }
475
476    /// Read the buffered body subject to the overall deadline.
477    ///
478    /// Only capping `send()` left a server trickling response headers/body
479    /// bytes forever after the headers arrived able to hang the caller
480    /// indefinitely; the total timeout must cover the body read too (SSE is
481    /// the deliberate exception and uses `bytes_stream()` directly).
482    async fn read_bounded_within(
483        &self,
484        resp: reqwest::Response,
485        deadline: tokio::time::Instant,
486    ) -> Result<BoundedResponse, HttpError> {
487        let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
488            Some(remaining) => remaining,
489            None => return Err(HttpError::Timeout(self.total_timeout)),
490        };
491        match tokio::time::timeout(remaining, self.read_bounded(resp)).await {
492            Ok(result) => result,
493            Err(_elapsed) => Err(HttpError::Timeout(self.total_timeout)),
494        }
495    }
496
497    async fn read_bounded(&self, resp: reqwest::Response) -> Result<BoundedResponse, HttpError> {
498        let status = resp.status();
499        let headers = resp.headers().clone();
500        let (body, truncated) = read_body_bounded(resp, self.max_bytes)
501            .await
502            .map_err(|e| HttpError::Transport(format!("read response body: {e}")))?;
503        if truncated {
504            return Err(HttpError::BodyTooLarge {
505                limit: self.max_bytes,
506            });
507        }
508        // Buffered calls (`get`/`post_json`) document non-2xx responses as
509        // HttpError::Status; convert here so callers never mistake an error
510        // body for a success payload to deserialize. Callers that need the raw
511        // status can use the request builder directly (SSE checks inline).
512        BoundedResponse {
513            status,
514            headers,
515            body,
516        }
517        .error_for_status()
518    }
519
520    /// Merges client-default headers, per-request headers and the resolved
521    /// bearer token (per-request override wins over the client default).
522    fn compose_headers(&self, opts: &RequestOptions) -> HeaderMap {
523        // Client defaults first, per-request headers override same names. Note:
524        // `HeaderMap::extend`/`append` would ADD a second value for an existing
525        // name (default + override both sent) — we `insert` so the per-request
526        // header replaces the default, honoring the documented override contract.
527        let mut headers = self.default_headers.clone();
528        for (k, v) in opts.headers.iter() {
529            headers.insert(k.clone(), v.clone());
530        }
531        let bearer = opts.bearer.as_deref().or(self.bearer.as_deref());
532        if let Some(token) = bearer {
533            if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}")) {
534                headers.insert(reqwest::header::AUTHORIZATION, value);
535            }
536        }
537        headers
538    }
539
540    fn prepare_request(
541        &self,
542        method: Method,
543        url: &str,
544        json: Option<&Value>,
545        opts: &RequestOptions,
546    ) -> reqwest::RequestBuilder {
547        let mut builder = self.client.request(method, url);
548        if let Some(json) = json {
549            builder = builder.json(json);
550        }
551        builder = builder.headers(self.compose_headers(opts));
552        builder
553    }
554
555    async fn execute(
556        &self,
557        method: Method,
558        url: &str,
559        json: Option<&Value>,
560        opts: &RequestOptions,
561    ) -> Result<(reqwest::Response, tokio::time::Instant), HttpError> {
562        let deadline = tokio::time::Instant::now() + self.total_timeout;
563        // Non-idempotent methods default to pre-dispatch-only retries
564        // unless the caller explicitly opts in, so a POST already on the
565        // wire cannot be duplicated by a transport timeout (0.25.0).
566        let retry_mode = effective_retry_mode(&method, opts, self.retry.transport);
567        let mut attempt: usize = 0;
568        loop {
569            if self
570                .cancel
571                .as_ref()
572                .is_some_and(CancellationToken::is_cancelled)
573            {
574                return Err(HttpError::Cancelled);
575            }
576            let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
577                Some(remaining) => remaining,
578                None => return Err(HttpError::Timeout(self.total_timeout)),
579            };
580            let request = self.prepare_request(method.clone(), url, json, opts);
581            match tokio::time::timeout(remaining, request.send()).await {
582                Err(_) => return Err(HttpError::Timeout(self.total_timeout)),
583                Ok(Ok(resp)) => {
584                    let status = resp.status();
585                    if attempt + 1 < self.retry.max_attempts && is_retryable_status(status) {
586                        let retry_after = parse_retry_after(resp.headers());
587                        drop(resp);
588                        self.wait(attempt, retry_after, deadline).await?;
589                        attempt += 1;
590                        continue;
591                    }
592                    return Ok((resp, deadline));
593                }
594                Ok(Err(err)) => {
595                    if attempt + 1 < self.retry.max_attempts
596                        && is_retryable_transport(&err, retry_mode)
597                    {
598                        self.wait(attempt, None, deadline).await?;
599                        attempt += 1;
600                        continue;
601                    }
602                    if err.is_timeout() {
603                        return Err(HttpError::Timeout(self.total_timeout));
604                    }
605                    return Err(HttpError::Transport(err.to_string()));
606                }
607            }
608        }
609    }
610
611    async fn wait(
612        &self,
613        attempt: usize,
614        retry_after: Option<Duration>,
615        deadline: tokio::time::Instant,
616    ) -> Result<(), HttpError> {
617        let backoff = compute_backoff(attempt, self.retry, random_entropy());
618        let delay = retry_after.map_or(backoff, |ra| ra.max(backoff));
619        let remaining = deadline
620            .checked_duration_since(tokio::time::Instant::now())
621            .unwrap_or(Duration::ZERO);
622        if remaining.is_zero() {
623            return Err(HttpError::Timeout(self.total_timeout));
624        }
625        let sleep_for = delay.min(remaining);
626        match &self.cancel {
627            Some(token) => {
628                tokio::select! {
629                    () = tokio::time::sleep(sleep_for) => {}
630                    () = token.cancelled() => return Err(HttpError::Cancelled),
631                }
632            }
633            None => tokio::time::sleep(sleep_for).await,
634        }
635        Ok(())
636    }
637}
638
639/// A fully buffered response with status and headers.
640#[derive(Debug, Clone)]
641pub struct BoundedResponse {
642    /// HTTP status code.
643    pub status: StatusCode,
644    /// Response headers.
645    pub headers: HeaderMap,
646    /// UTF-8 response body.
647    pub body: String,
648}
649
650impl BoundedResponse {
651    /// Returns true for 2xx statuses.
652    pub fn is_success(&self) -> bool {
653        self.status.is_success()
654    }
655
656    /// Converts a non-2xx response into [`HttpError::Status`].
657    pub fn error_for_status(self) -> Result<Self, HttpError> {
658        if self.is_success() {
659            Ok(self)
660        } else {
661            Err(HttpError::Status {
662                status: self.status.as_u16(),
663                body: truncate_for_error(&self.body),
664            })
665        }
666    }
667}
668
669/// Boxed SSE byte stream.
670pub type SseStream = Pin<Box<dyn Stream<Item = io::Result<bytes::Bytes>> + Send>>;
671
672/// Returns true for the closed set of retriable statuses:
673/// 408, 429, 500, 502, 503, 504. Notably 501/505 are NOT retriable.
674pub fn is_retryable_status(status: StatusCode) -> bool {
675    matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
676}
677
678/// Selects the retry classification for one attempt: an explicit per-request
679/// override wins; otherwise safe methods (GET/HEAD) keep the client policy
680/// while non-idempotent methods (POST/PUT/PATCH/DELETE) fall back to
681/// pre-dispatch-only so a request already on the wire is never duplicated
682/// by a retry (0.25.0).
683fn effective_retry_mode(
684    method: &Method,
685    opts: &RequestOptions,
686    client_mode: TransportRetryMode,
687) -> TransportRetryMode {
688    match opts.retry_mode {
689        Some(mode) => mode,
690        None if method.is_safe() => client_mode,
691        None => TransportRetryMode::PreDispatchOnly,
692    }
693}
694
695fn is_retryable_transport(err: &reqwest::Error, mode: TransportRetryMode) -> bool {
696    if err.is_connect() {
697        return true;
698    }
699    matches!(mode, TransportRetryMode::AllTransportErrors) && (err.is_timeout() || err.is_request())
700}
701
702/// Parses a `Retry-After` header, accepting both delta-seconds and an
703/// IMF-fixdate. A date in the past resolves to [`Duration::ZERO`].
704pub fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
705    let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
706    if let Ok(seconds) = value.parse::<u64>() {
707        return Some(Duration::from_secs(seconds));
708    }
709    let date = httpdate::parse_http_date(value).ok()?;
710    Some(
711        date.duration_since(SystemTime::now())
712            .unwrap_or(Duration::ZERO),
713    )
714}
715
716/// Full-jitter backoff: uniform in `[0, min(base * 2^attempt, max_delay)]`.
717pub fn compute_backoff(attempt: usize, policy: RetryPolicy, entropy_nanos: u64) -> Duration {
718    // Duration::checked_mul takes u32; attempts past 31 saturate the multiplier.
719    let shift = (attempt as u32).min(31);
720    let multiplier = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
721    let uncapped = policy
722        .base_delay
723        .checked_mul(multiplier)
724        .unwrap_or(policy.max_delay);
725    let cap = uncapped.min(policy.max_delay);
726    let span = cap.as_nanos();
727    if span == 0 {
728        return Duration::ZERO;
729    }
730    // u128 -> u64 is safe: cap is bounded by max_delay (seconds, not ages).
731    let jitter = entropy_nanos % (span as u64);
732    Duration::from_nanos(jitter)
733}
734
735fn random_entropy() -> u64 {
736    // B1: full-jitter's sample window spans `[0, min(base*2^attempt, max_delay)]`.
737    // Using only `subsec_nanos` caps the entropy at <1e9, so a `max_delay` above 1s
738    // could never be reached by the jitter. Mix the whole timestamp (seconds ^ nanos)
739    // into the entropy so it spans the u64 domain, while still avoiding pulling a
740    // `rand` dependency through the core crate.
741    SystemTime::now()
742        .duration_since(UNIX_EPOCH)
743        .map(|d| d.as_secs() ^ d.subsec_nanos() as u64)
744        .unwrap_or(0)
745}
746
747fn truncate_for_error(body: &str) -> String {
748    const MAX_ERROR_CHARS: usize = 2_000;
749    if body.chars().count() <= MAX_ERROR_CHARS {
750        body.to_string()
751    } else {
752        let truncated: String = body.chars().take(MAX_ERROR_CHARS).collect();
753        format!("{truncated}…(truncated)")
754    }
755}
756
757/// Canonical base-URL normalization used by every provider config.
758///
759/// Trims whitespace and a single trailing slash, rejects non-http(s) schemes
760/// and empty hosts.
761pub fn normalize_base_url(raw: &str) -> Result<String, HttpError> {
762    let trimmed = raw.trim().trim_end_matches('/');
763    let parsed = url::Url::parse(trimmed).map_err(|e| HttpError::InvalidUrl {
764        url: raw.to_string(),
765        reason: e.to_string(),
766    })?;
767    if !matches!(parsed.scheme(), "http" | "https") {
768        return Err(HttpError::InvalidUrl {
769            url: raw.to_string(),
770            reason: format!("unsupported scheme {}", parsed.scheme()),
771        });
772    }
773    if parsed.host_str().is_none_or(str::is_empty) {
774        return Err(HttpError::InvalidUrl {
775            url: raw.to_string(),
776            reason: "missing host".to_string(),
777        });
778    }
779    Ok(trimmed.to_string())
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use std::io::{Read, Write};
786    use std::net::TcpListener;
787    use std::sync::atomic::AtomicUsize;
788    use std::sync::Arc;
789
790    fn fast_retry() -> RetryPolicy {
791        RetryPolicy {
792            max_attempts: 3,
793            base_delay: Duration::from_millis(1),
794            max_delay: Duration::from_millis(5),
795            ..Default::default()
796        }
797    }
798
799    // ---- pure unit tests ------------------------------------------------
800
801    #[test]
802    fn retryable_status_set() {
803        for code in [408u16, 429, 500, 502, 503, 504] {
804            assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
805        }
806        for code in [400, 401, 403, 404, 409, 422, 501, 505] {
807            assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
808        }
809    }
810
811    #[test]
812    fn retry_after_delta_seconds() {
813        let mut headers = HeaderMap::new();
814        headers.insert(RETRY_AFTER, HeaderValue::from_static("42"));
815        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(42)));
816    }
817
818    #[test]
819    fn retry_after_http_date() {
820        let mut headers = HeaderMap::new();
821        // Fixed date far in the past → ZERO, no waiting in tests.
822        headers.insert(
823            RETRY_AFTER,
824            HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
825        );
826        assert_eq!(parse_retry_after(&headers), Some(Duration::ZERO));
827    }
828
829    #[test]
830    fn retry_after_future_http_date_is_some() {
831        let mut headers = HeaderMap::new();
832        // Roughly one hour in the future, formatted as IMF-fixdate.
833        let future = SystemTime::now() + Duration::from_secs(3600);
834        headers.insert(
835            RETRY_AFTER,
836            HeaderValue::from_str(&httpdate::fmt_http_date(future)).unwrap(),
837        );
838        let parsed = parse_retry_after(&headers).unwrap();
839        assert!(parsed >= Duration::from_secs(3500) && parsed <= Duration::from_secs(3600));
840    }
841
842    #[test]
843    fn backoff_always_bounded_by_cap() {
844        let policy = fast_retry();
845        for attempt in 0..10u32 {
846            for entropy in [0u64, 1, 7, u64::MAX] {
847                let d = compute_backoff(attempt as usize, policy, entropy);
848                assert!(d <= policy.max_delay, "attempt {attempt}");
849            }
850        }
851    }
852
853    #[test]
854    fn normalizes_base_urls() {
855        assert_eq!(
856            normalize_base_url("https://api.example.com/v1/").unwrap(),
857            "https://api.example.com/v1"
858        );
859        assert_eq!(
860            normalize_base_url("  http://localhost:8080  ").unwrap(),
861            "http://localhost:8080"
862        );
863        assert!(normalize_base_url("ftp://api.example.com").is_err());
864        assert!(normalize_base_url("not a url").is_err());
865        assert!(normalize_base_url("https://").is_err());
866    }
867
868    // ---- raw TCP loopback stubs -----------------------------------------
869    //
870    // A plain TcpListener is used (not hyper): the sandbox blocks real HTTP
871    // servers but raw loopback sockets work, and hand-written HTTP/1.1 is
872    // enough to exercise status/retry/header behavior.
873
874    /// One scripted response. The last entry is repeated for any extra
875    /// request. Each response closes the connection.
876    #[derive(Debug, Clone)]
877    struct StubResponse {
878        status: u16,
879        reason: &'static str,
880        extra_headers: &'static str,
881        body: Vec<u8>,
882    }
883
884    fn spawn_stub(script: Vec<StubResponse>) -> (String, Arc<AtomicUsize>) {
885        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
886        let addr = listener.local_addr().unwrap();
887        let count = Arc::new(AtomicUsize::new(0));
888        let count_task = count.clone();
889        std::thread::spawn(move || {
890            for mut stream in listener.incoming().flatten() {
891                let idx = count_task.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
892                // Drain the request head (and any body) so the client can
893                // finish sending.
894                let mut buf = [0u8; 4096];
895                let _ = stream.read(&mut buf);
896                let resp = script
897                    .get(idx)
898                    .unwrap_or_else(|| script.last().expect("empty stub script"))
899                    .clone();
900                let head = format!(
901                    "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n",
902                    resp.status,
903                    resp.reason,
904                    resp.body.len(),
905                    resp.extra_headers
906                );
907                let _ = stream.write_all(head.as_bytes());
908                let _ = stream.write_all(&resp.body);
909                let _ = stream.flush();
910            }
911        });
912        (format!("http://127.0.0.1:{}", addr.port()), count)
913    }
914
915    fn body_ok(text: &str) -> StubResponse {
916        StubResponse {
917            status: 200,
918            reason: "OK",
919            extra_headers: "Content-Type: application/json\r\n",
920            body: text.as_bytes().to_vec(),
921        }
922    }
923
924    fn status_response(status: u16, reason: &'static str) -> StubResponse {
925        StubResponse {
926            status,
927            reason,
928            extra_headers: "",
929            body: b"{}".to_vec(),
930        }
931    }
932
933    // ---- loopback integration tests -------------------------------------
934
935    #[tokio::test]
936    async fn success_is_not_retried() {
937        let (url, count) = spawn_stub(vec![body_ok("{\"ok\":true}")]);
938        let client = HttpClient::api()
939            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
940            .retry(fast_retry())
941            .build()
942            .unwrap();
943        let resp = client.get(&url).await.unwrap();
944        assert!(resp.is_success());
945        assert_eq!(resp.body, "{\"ok\":true}");
946        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
947    }
948
949    #[tokio::test]
950    async fn retries_503_twice_then_succeeds() {
951        let script = vec![
952            status_response(503, "Service Unavailable"),
953            status_response(503, "Service Unavailable"),
954            body_ok("{}"),
955        ];
956        let (url, count) = spawn_stub(script);
957        let client = HttpClient::api()
958            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
959            .retry(fast_retry())
960            .build()
961            .unwrap();
962        let resp = client.get(&url).await.unwrap();
963        assert_eq!(resp.status, 200);
964        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 3);
965    }
966
967    #[tokio::test]
968    async fn status_501_returns_immediately() {
969        let (url, count) = spawn_stub(vec![status_response(501, "Not Implemented")]);
970        let client = HttpClient::api().retry(fast_retry()).build().unwrap();
971        // Buffered calls document non-2xx as HttpError::Status (error bodies
972        // must never reach a success-payload deserializer).
973        let err = client.get(&url).await.expect_err("501 is an error");
974        assert!(
975            matches!(err, HttpError::Status { status: 501, .. }),
976            "expected HttpError::Status 501, got {err:?}"
977        );
978        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
979    }
980
981    #[tokio::test]
982    async fn status_505_returns_immediately() {
983        let (url, count) = spawn_stub(vec![status_response(505, "HTTP Version Not Supported")]);
984        let client = HttpClient::api().retry(fast_retry()).build().unwrap();
985        let err = client.get(&url).await.expect_err("505 is an error");
986        assert!(
987            matches!(err, HttpError::Status { status: 505, .. }),
988            "expected HttpError::Status 505, got {err:?}"
989        );
990        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
991    }
992
993    #[tokio::test]
994    async fn retry_after_zero_does_not_delay() {
995        let script = vec![
996            StubResponse {
997                status: 429,
998                reason: "Too Many Requests",
999                extra_headers: "Retry-After: 0\r\n",
1000                body: b"{}".to_vec(),
1001            },
1002            body_ok("{}"),
1003        ];
1004        let (url, count) = spawn_stub(script);
1005        let client = HttpClient::api()
1006            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1007            // Even a large configured delay must be irrelevant when the
1008            // server says Retry-After: 0 (max with backoff ≥ 0).
1009            .retry(RetryPolicy {
1010                max_attempts: 3,
1011                base_delay: Duration::from_secs(1),
1012                max_delay: Duration::from_secs(2),
1013                ..Default::default()
1014            })
1015            .build()
1016            .unwrap();
1017        let start = std::time::Instant::now();
1018        let resp = client.get(&url).await.unwrap();
1019        assert_eq!(resp.status, 200);
1020        assert!(start.elapsed() < Duration::from_secs(1));
1021        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1022    }
1023
1024    #[tokio::test]
1025    async fn retry_after_past_http_date_does_not_delay() {
1026        let script = vec![
1027            StubResponse {
1028                status: 503,
1029                reason: "Service Unavailable",
1030                extra_headers: "Retry-After: Wed, 21 Oct 2015 07:28:00 GMT\r\n",
1031                body: b"{}".to_vec(),
1032            },
1033            body_ok("{}"),
1034        ];
1035        let (url, _count) = spawn_stub(script);
1036        let client = HttpClient::api()
1037            .retry(RetryPolicy {
1038                max_attempts: 3,
1039                base_delay: Duration::from_millis(1),
1040                max_delay: Duration::from_millis(5),
1041                ..Default::default()
1042            })
1043            .build()
1044            .unwrap();
1045        let start = std::time::Instant::now();
1046        let resp = client.get(&url).await.unwrap();
1047        assert_eq!(resp.status, 200);
1048        assert!(start.elapsed() < Duration::from_secs(1));
1049    }
1050
1051    #[tokio::test]
1052    async fn body_over_limit_errors() {
1053        let big = vec![b'x'; 200_000];
1054        let (url, _count) = spawn_stub(vec![StubResponse {
1055            status: 200,
1056            reason: "OK",
1057            extra_headers: "",
1058            body: big,
1059        }]);
1060        let client = HttpClient::api()
1061            .retry(RetryPolicy::none())
1062            .max_bytes(1024)
1063            .build()
1064            .unwrap();
1065        let err = client.get(&url).await.unwrap_err();
1066        assert!(
1067            matches!(err, HttpError::BodyTooLarge { limit: 1024 }),
1068            "got {err:?}"
1069        );
1070    }
1071
1072    #[tokio::test]
1073    async fn total_timeout_fires_against_blackhole() {
1074        // Accept but never respond.
1075        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1076        let addr = listener.local_addr().unwrap();
1077        std::thread::spawn(move || {
1078            for stream in listener.incoming().flatten() {
1079                // Hold the connection open without writing until the client
1080                // gives up; the socket drops when the thread ends the loop.
1081                let _owned = stream;
1082                std::thread::sleep(Duration::from_secs(10));
1083            }
1084        });
1085        let url = format!("http://127.0.0.1:{}", addr.port());
1086        let client = HttpClient::api()
1087            .timeouts(Duration::from_secs(5), Duration::from_millis(200))
1088            .retry(fast_retry())
1089            .build()
1090            .unwrap();
1091        let err = client.get(&url).await.unwrap_err();
1092        assert!(matches!(err, HttpError::Timeout(_)), "got {err:?}");
1093    }
1094
1095    #[tokio::test]
1096    async fn pre_cancelled_token_aborts_before_request() {
1097        let (url, count) = spawn_stub(vec![body_ok("{}")]);
1098        let token = CancellationToken::new();
1099        token.cancel();
1100        let client = HttpClient::api()
1101            .retry(fast_retry())
1102            .cancellation_token(token)
1103            .build()
1104            .unwrap();
1105        let err = client.get(&url).await.unwrap_err();
1106        assert!(matches!(err, HttpError::Cancelled));
1107        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
1108    }
1109
1110    #[tokio::test]
1111    async fn cancel_during_retry_wait_aborts() {
1112        let script = vec![
1113            StubResponse {
1114                status: 503,
1115                reason: "Service Unavailable",
1116                extra_headers: "Retry-After: 30\r\n",
1117                body: b"{}".to_vec(),
1118            },
1119            body_ok("{}"),
1120        ];
1121        let (url, count) = spawn_stub(script);
1122        let token = CancellationToken::new();
1123        let canceller = token.clone();
1124        std::thread::spawn(move || {
1125            std::thread::sleep(Duration::from_millis(50));
1126            canceller.cancel();
1127        });
1128        let client = HttpClient::api()
1129            .timeouts(Duration::from_secs(5), Duration::from_secs(30))
1130            .retry(RetryPolicy {
1131                max_attempts: 3,
1132                base_delay: Duration::from_millis(1),
1133                max_delay: Duration::from_millis(5),
1134                ..Default::default()
1135            })
1136            .cancellation_token(token)
1137            .build()
1138            .unwrap();
1139        let start = std::time::Instant::now();
1140        let err = client.get(&url).await.unwrap_err();
1141        assert!(matches!(err, HttpError::Cancelled), "got {err:?}");
1142        assert!(start.elapsed() < Duration::from_secs(2));
1143        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
1144    }
1145
1146    #[tokio::test]
1147    async fn sse_retries_until_stream_opens() {
1148        let script = vec![
1149            status_response(503, "Service Unavailable"),
1150            StubResponse {
1151                status: 200,
1152                reason: "OK",
1153                extra_headers: "Content-Type: text/event-stream\r\n",
1154                body: b"data: one\n\ndata: two\n\n".to_vec(),
1155            },
1156        ];
1157        let (url, count) = spawn_stub(script);
1158        let client = HttpClient::sse()
1159            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1160            .retry(fast_retry())
1161            .build()
1162            .unwrap();
1163        let mut stream = client
1164            .open_sse(&url, None, RequestOptions::new())
1165            .await
1166            .unwrap();
1167        let mut collected = Vec::new();
1168        while let Some(chunk) = stream.next().await {
1169            collected.extend_from_slice(&chunk.unwrap());
1170        }
1171        assert_eq!(collected, b"data: one\n\ndata: two\n\n");
1172        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1173    }
1174
1175    #[tokio::test]
1176    async fn sse_mid_stream_close_does_not_reconnect() {
1177        let script = vec![StubResponse {
1178            status: 200,
1179            reason: "OK",
1180            extra_headers: "Content-Type: text/event-stream\r\n",
1181            body: b"data: only-one-event\n\n".to_vec(),
1182        }];
1183        let (url, count) = spawn_stub(script);
1184        let client = HttpClient::sse().retry(fast_retry()).build().unwrap();
1185        let mut stream = client
1186            .open_sse(&url, None, RequestOptions::new())
1187            .await
1188            .unwrap();
1189        let mut collected = Vec::new();
1190        while let Some(chunk) = stream.next().await {
1191            collected.extend_from_slice(&chunk.unwrap());
1192        }
1193        assert_eq!(collected, b"data: only-one-event\n\n");
1194        // Give a potentially-wrong reconnect implementation a moment to act.
1195        tokio::time::sleep(Duration::from_millis(50)).await;
1196        assert_eq!(
1197            count.load(std::sync::atomic::Ordering::SeqCst),
1198            1,
1199            "mid-stream close must not trigger a reconnect"
1200        );
1201    }
1202
1203    #[tokio::test]
1204    async fn sse_error_status_is_reported_with_body() {
1205        let (url, _count) = spawn_stub(vec![StubResponse {
1206            status: 400,
1207            reason: "Bad Request",
1208            extra_headers: "",
1209            body: b"{\"error\":\"bad payload\"}".to_vec(),
1210        }]);
1211        let client = HttpClient::sse()
1212            .retry(RetryPolicy::none())
1213            .build()
1214            .unwrap();
1215        // Use let-else rather than unwrap_err(): the success value is a boxed
1216        // stream with no Debug impl.
1217        let Err(HttpError::Status { status, body }) =
1218            client.open_sse(&url, None, RequestOptions::new()).await
1219        else {
1220            panic!("expected an HttpError::Status");
1221        };
1222        assert_eq!(status, 400);
1223        assert_eq!(body, "{\"error\":\"bad payload\"}");
1224    }
1225
1226    #[tokio::test]
1227    async fn per_request_bearer_header_is_sent() {
1228        // The stub reads the head into its buffer but discards it; verify
1229        // header assembly with a dedicated echo stub instead.
1230        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1231        let addr = listener.local_addr().unwrap();
1232        std::thread::spawn(move || {
1233            for mut stream in listener.incoming().flatten() {
1234                let mut buf = Vec::new();
1235                let mut tmp = [0u8; 4096];
1236                while let Ok(n) = stream.read(&mut tmp) {
1237                    if n == 0 {
1238                        break;
1239                    }
1240                    buf.extend_from_slice(&tmp[..n]);
1241                    if buf.windows(4).any(|w| w == b"\r\n\r\n") {
1242                        break;
1243                    }
1244                }
1245                // hyper 1.x emits header names in lowercase on the wire.
1246                let echo = String::from_utf8_lossy(&buf).to_string().to_lowercase();
1247                let answer = format!(
1248                    "{{\"seen\":\"{}\"}}",
1249                    echo.contains("authorization: bearer secret")
1250                );
1251                let head = format!(
1252                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1253                    answer.len()
1254                );
1255                let _ = stream.write_all(head.as_bytes());
1256                let _ = stream.write_all(answer.as_bytes());
1257            }
1258        });
1259        let url = format!("http://127.0.0.1:{}", addr.port());
1260        let client = HttpClient::api()
1261            .retry(RetryPolicy::none())
1262            .build()
1263            .unwrap();
1264        let resp = client
1265            .post_json_with(
1266                &url,
1267                &serde_json::json!({"a": 1}),
1268                RequestOptions::new().bearer("secret"),
1269            )
1270            .await
1271            .unwrap();
1272        assert_eq!(resp.body, "{\"seen\":\"true\"}");
1273    }
1274
1275    // ---- 0.25.0 MEDIUM regressions: body deadline + POST retry safety ----
1276
1277    #[test]
1278    fn effective_retry_mode_is_method_aware() {
1279        let client_policy = TransportRetryMode::AllTransportErrors;
1280        // Safe methods follow the client policy.
1281        assert_eq!(
1282            effective_retry_mode(&Method::GET, &RequestOptions::new(), client_policy),
1283            TransportRetryMode::AllTransportErrors
1284        );
1285        assert_eq!(
1286            effective_retry_mode(&Method::HEAD, &RequestOptions::new(), client_policy),
1287            TransportRetryMode::AllTransportErrors
1288        );
1289        // Non-idempotent methods default to pre-dispatch-only, regardless of
1290        // the client policy, so a request already on the wire is not duplicated.
1291        for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
1292            assert_eq!(
1293                effective_retry_mode(&method, &RequestOptions::new(), client_policy),
1294                TransportRetryMode::PreDispatchOnly,
1295                "{method} must default to PreDispatchOnly"
1296            );
1297        }
1298        // An explicit per-request override wins for every method.
1299        assert_eq!(
1300            effective_retry_mode(
1301                &Method::POST,
1302                &RequestOptions::new().retry_mode(TransportRetryMode::AllTransportErrors),
1303                TransportRetryMode::PreDispatchOnly,
1304            ),
1305            TransportRetryMode::AllTransportErrors
1306        );
1307        assert_eq!(
1308            effective_retry_mode(
1309                &Method::GET,
1310                &RequestOptions::new().retry_mode(TransportRetryMode::PreDispatchOnly),
1311                TransportRetryMode::AllTransportErrors,
1312            ),
1313            TransportRetryMode::PreDispatchOnly
1314        );
1315    }
1316
1317    /// Stub that fully drains one request per connection then closes without
1318    /// sending any response. reqwest surfaces this as a post-dispatch request
1319    /// error (connection closed before message completed), not a connect
1320    /// error: the TCP connection was accepted and the bytes were sent.
1321    fn spawn_close_after_dispatch_stub() -> (String, Arc<AtomicUsize>) {
1322        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1323        let addr = listener.local_addr().unwrap();
1324        let count = Arc::new(AtomicUsize::new(0));
1325        let count_task = count.clone();
1326        std::thread::spawn(move || {
1327            for mut stream in listener.incoming().flatten() {
1328                count_task.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1329                // Drain the whole request (head + body) so the client has
1330                // definitely dispatched before the connection drops.
1331                // Bound the drain: the client is waiting for a response, so a
1332                // blocking read for an unsent body byte would deadlock until
1333                // the client deadline. Loopback coalesces the tiny request.
1334                let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
1335                let mut buf = [0u8; 4096];
1336                loop {
1337                    match stream.read(&mut buf) {
1338                        Ok(0) => break,
1339                        Ok(_) => {}
1340                        Err(_) => break,
1341                    }
1342                }
1343                // Drop without writing: client sees an incomplete response.
1344                let _ = stream.shutdown(std::net::Shutdown::Both);
1345            }
1346        });
1347        (format!("http://127.0.0.1:{}", addr.port()), count)
1348    }
1349
1350    #[tokio::test]
1351    async fn post_transport_error_after_dispatch_is_not_retried_by_default() {
1352        // 0.25.0 MEDIUM: a POST whose bytes reached the server must not be
1353        // blindly retried (double-charge / duplicate side effect risk).
1354        let (url, count) = spawn_close_after_dispatch_stub();
1355        let client = HttpClient::api()
1356            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1357            .retry(fast_retry())
1358            .build()
1359            .unwrap();
1360        let err = client
1361            .post_json(&url, &serde_json::json!({"id": 1}))
1362            .await
1363            .unwrap_err();
1364        assert!(matches!(err, HttpError::Transport(_)), "got {err:?}");
1365        assert_eq!(
1366            count.load(std::sync::atomic::Ordering::SeqCst),
1367            1,
1368            "dispatched POST must not be retried under PreDispatchOnly"
1369        );
1370    }
1371
1372    #[tokio::test]
1373    async fn post_retries_after_dispatch_only_with_explicit_opt_in() {
1374        // The escape hatch: a known-idempotent or server-deduplicated POST can
1375        // restore AllTransportErrors per request.
1376        let (url, count) = spawn_close_after_dispatch_stub();
1377        let client = HttpClient::api()
1378            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1379            .retry(fast_retry())
1380            .build()
1381            .unwrap();
1382        let result = client
1383            .post_json_with(
1384                &url,
1385                &serde_json::json!({"id": 1}),
1386                RequestOptions::new().retry_mode(TransportRetryMode::AllTransportErrors),
1387            )
1388            .await;
1389        assert!(result.is_err());
1390        assert_eq!(
1391            count.load(std::sync::atomic::Ordering::SeqCst),
1392            3,
1393            "explicit AllTransportErrors must retry the dispatched POST"
1394        );
1395    }
1396
1397    #[tokio::test]
1398    async fn get_still_retries_after_dispatch_under_default_policy() {
1399        // Contrast case: safe methods keep the client-level retry policy.
1400        let (url, count) = spawn_close_after_dispatch_stub();
1401        let client = HttpClient::api()
1402            .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1403            .retry(fast_retry())
1404            .build()
1405            .unwrap();
1406        assert!(client.get(&url).await.is_err());
1407        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 3);
1408    }
1409
1410    #[tokio::test]
1411    async fn buffered_body_read_respects_total_timeout() {
1412        // 0.25.0 MEDIUM: a server that sends headers then trickles body bytes
1413        // forever must not hang the caller past the total deadline. The
1414        // deadline covers send() AND the buffered body read (not just send).
1415        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1416        let addr = listener.local_addr().unwrap();
1417        std::thread::spawn(move || {
1418            for mut stream in listener.incoming().flatten() {
1419                let mut buf = [0u8; 4096];
1420                let _ = stream.read(&mut buf);
1421                let head = "HTTP/1.1 200 OK
1422Content-Length: 64
1423Connection: close
1424
1425";
1426                let _ = stream.write_all(head.as_bytes());
1427                // Only 5 of the 64 promised bytes...
1428                let _ = stream.write_all(b"hello");
1429                let _ = stream.flush();
1430                // ...then withhold the remaining bytes until the client exits.
1431                std::thread::sleep(Duration::from_secs(5));
1432            }
1433        });
1434        let url = format!("http://127.0.0.1:{}", addr.port());
1435        let client = HttpClient::api()
1436            .timeouts(Duration::from_secs(5), Duration::from_millis(300))
1437            .retry(RetryPolicy::none())
1438            .build()
1439            .unwrap();
1440        let start = std::time::Instant::now();
1441        let err = client.get(&url).await.unwrap_err();
1442        assert!(matches!(err, HttpError::Timeout(_)), "got {err:?}");
1443        assert!(
1444            start.elapsed() < Duration::from_secs(2),
1445            "body read must be bounded by the total deadline"
1446        );
1447    }
1448}