Skip to main content

autumn_web/
http_client.rs

1//! Traced outbound HTTP client with retries and test mocks.
2//!
3//! Exposes [`Client`](crate::http_client::Client) as `autumn_web::http::Client` — a thin `reqwest`-backed
4//! outbound HTTP client that propagates the active span's `traceparent` /
5//! `tracestate` headers, retries transient failures, and is mockable in tests
6//! via [`TestApp::http_mock`](crate::test::TestApp::http_mock).
7//!
8//! # Quick start
9//!
10//! ```rust,no_run
11//! use autumn_web::prelude::*;
12//! use autumn_web::http::Client;
13//!
14//! #[post("/pay")]
15//! async fn pay(client: Client) -> AutumnResult<Json<serde_json::Value>> {
16//!     let resp = client
17//!         .post("https://api.stripe.com/v1/charges")
18//!         .header("authorization", "Bearer sk_test_xxx")
19//!         .json(&serde_json::json!({"amount": 1000, "currency": "usd"}))
20//!         .send()
21//!         .await?;
22//!     Ok(Json(resp.json()?))
23//! }
24//! ```
25//!
26//! # Test mocks
27//!
28//! ```rust,no_run
29//! use autumn_web::test::TestApp;
30//! use autumn_web::prelude::*;
31//! use serde_json::json;
32//!
33//! // (handler shown above)
34//!
35//! #[tokio::test]
36//! async fn pay_calls_stripe() {
37//!     let mut app = TestApp::new().routes(routes![pay]);
38//!     let mock = app.http_mock("stripe")
39//!         .post("/v1/charges")
40//!         .respond_with(200, json!({"id": "ch_123", "amount": 1000}));
41//!
42//!     let client = app.build();
43//!     client.post("/pay").send().await.assert_status(200);
44//!     mock.expect_called(1);
45//! }
46//! ```
47
48use std::collections::HashMap;
49use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
50use std::sync::atomic::{AtomicUsize, Ordering};
51use std::sync::{Arc, Mutex};
52use std::time::{Duration, Instant};
53
54use bytes::Bytes;
55use reqwest::Method;
56use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
57use serde::Serialize;
58use serde::de::DeserializeOwned;
59
60// ── Error ────────────────────────────────────────────────────────────────────
61
62/// Errors produced by [`Client`] and [`RequestBuilder`].
63#[derive(Debug, thiserror::Error)]
64pub enum ClientError {
65    /// An underlying `reqwest` transport error.
66    #[error("outbound HTTP request failed: {0}")]
67    Request(#[from] reqwest::Error),
68    /// JSON (de)serialisation failed.
69    #[error("JSON error: {0}")]
70    Json(#[from] serde_json::Error),
71    /// No mock entry matched the outgoing request.
72    #[error("no mock registered for {0} {1}")]
73    NoMock(String, String),
74    /// The outbound circuit breaker is open.
75    #[error("outbound circuit breaker is open")]
76    CircuitBreakerOpen,
77    /// A resolved connection target (or redirect target) is a blocked
78    /// (private / link-local / loopback / reserved) IP address per the built-in
79    /// SSRF policy. See [`is_blocked_ip`].
80    #[error("SSRF policy blocked address: {0}")]
81    SsrfBlocked(String),
82    /// A redirect chain exceeded the configured maximum number of hops.
83    #[error("too many redirects (max {0})")]
84    TooManyRedirects(usize),
85    /// A redirect's absolute `Location` target was rejected by the caller's
86    /// validator (or by the built-in scheme-downgrade guard).
87    #[error("redirect rejected: {0}")]
88    RedirectRejected(String),
89    /// A URL could not be parsed, was missing a host, or DNS resolution failed
90    /// while composing a custom (redirect / pin / SSRF-safe) request.
91    #[error("invalid or unresolvable URL: {0}")]
92    InvalidUrl(String),
93    /// [`pin_to`](RequestBuilder::pin_to) was combined with
94    /// [`follow_redirects`](RequestBuilder::follow_redirects) — an incompatible
95    /// pair. A single pinned `SocketAddr` only covers the first hop; later
96    /// redirect hops re-resolve via normal DNS, so following a cross-host `3xx`
97    /// would silently escape the pin and defeat its purpose. Use
98    /// [`Client::get_ssrf_safe`] for pinned, per-hop-revalidated redirect
99    /// following; `pin_to` alone (returns the `3xx` unfollowed); or
100    /// `follow_redirects` without `pin_to`.
101    #[error("{0}")]
102    IncompatiblePinRedirect(&'static str),
103    /// [`pin_to`](RequestBuilder::pin_to) was used on a request whose URL host is
104    /// an IP literal. reqwest/hyper treat an IP-literal host as already-resolved
105    /// and never consult the DNS resolver, so the `resolve_to_addrs` override
106    /// that installs the pin is skipped and the socket connects to the literal in
107    /// the URL — not the pinned address — silently bypassing the pin. `pin_to`
108    /// therefore requires a domain (hostname) host; put the desired IP directly
109    /// in the URL, or use a domain host. (`get_ssrf_safe` never sets a pin: it
110    /// validates the literal and connects to that same IP, so it is unaffected.)
111    #[error("{0}")]
112    PinRequiresDomainHost(&'static str),
113    /// [`pin_to`](RequestBuilder::pin_to) was combined with
114    /// [`Client::get_ssrf_safe`] — an incompatible pair. The SSRF-safe path runs
115    /// its own per-hop resolve→validate→pin and never reads the caller's
116    /// `pin_to` address, so an explicit pin would be silently ignored. Use
117    /// `pin_to` alone for a caller-chosen fixed address, or `get_ssrf_safe`
118    /// alone for guarded automatic per-hop pinning — not both.
119    #[error("{0}")]
120    PinNotAllowedWithSsrfSafe(&'static str),
121}
122
123// ── Response ─────────────────────────────────────────────────────────────────
124
125/// Completed outbound HTTP response with eagerly-collected body bytes.
126///
127/// Body is consumed once — call exactly one of [`json`](Self::json),
128/// [`text`](Self::text), or [`bytes`](Self::bytes).
129#[derive(Debug)]
130pub struct Response {
131    status: reqwest::StatusCode,
132    headers: HeaderMap,
133    body: Bytes,
134    url: Option<reqwest::Url>,
135}
136
137impl Response {
138    /// HTTP status code.
139    pub const fn status(&self) -> reqwest::StatusCode {
140        self.status
141    }
142
143    /// Response headers (sensitive values are **not** redacted here).
144    pub const fn headers(&self) -> &HeaderMap {
145        &self.headers
146    }
147
148    /// `true` when the status code is in the 2xx range.
149    pub fn is_success(&self) -> bool {
150        self.status.is_success()
151    }
152
153    /// URL that was ultimately requested (after redirects, if any).
154    pub const fn url(&self) -> Option<&reqwest::Url> {
155        self.url.as_ref()
156    }
157
158    /// Deserialise the body as JSON.
159    ///
160    /// # Errors
161    /// Returns [`ClientError::Json`] if the body is not valid JSON for `T`.
162    pub fn json<T: DeserializeOwned>(self) -> Result<T, ClientError> {
163        serde_json::from_slice(&self.body).map_err(ClientError::Json)
164    }
165
166    /// Return the body as a UTF-8 string (lossy).
167    pub fn text(self) -> String {
168        String::from_utf8_lossy(&self.body).into_owned()
169    }
170
171    /// Return the raw body bytes.
172    pub fn bytes(self) -> Bytes {
173        self.body
174    }
175}
176
177// ── SSRF address policy ──────────────────────────────────────────────────────
178
179/// Return `true` when `ip` must **not** be connected to because it belongs to a
180/// private, loopback, link-local, CGNAT, benchmarking, documentation, multicast
181/// or otherwise reserved range.
182///
183/// This is the built-in Server-Side Request Forgery (SSRF) deny-list used by
184/// [`Client::get_ssrf_safe`]. Ranges are checked explicitly (rather than via the
185/// unstable `IpAddr::is_global` family) so the code compiles on stable Rust.
186///
187/// IPv6 addresses that embed an IPv4 via a transition mechanism — IPv4-mapped
188/// (`::ffff:a.b.c.d`), the deprecated IPv4-compatible (`::a.b.c.d`), NAT64
189/// (`64:ff9b::/96`), 6to4 (`2002::/16`), and the SIIT IPv4-translated prefix
190/// (`::ffff:0:0:0/96`) — are unwrapped and re-checked as IPv4, so encodings
191/// such as `::ffff:169.254.169.254`, `64:ff9b::a9fe:a9fe`, `2002:a9fe:a9fe::`,
192/// and `::ffff:0:169.254.169.254` are all correctly blocked.
193#[must_use]
194pub fn is_blocked_ip(ip: IpAddr) -> bool {
195    match ip {
196        IpAddr::V4(v4) => is_blocked_ipv4(v4),
197        IpAddr::V6(v6) => is_blocked_ipv6(v6),
198    }
199}
200
201/// Return `true` when `ip` is safe to connect to — the exact negation of
202/// [`is_blocked_ip`].
203#[must_use]
204pub fn is_public_ip(ip: IpAddr) -> bool {
205    !is_blocked_ip(ip)
206}
207
208fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
209    let [a, b, c, _d] = ip.octets();
210    // 0.0.0.0/8 (incl. unspecified)
211    if a == 0 {
212        return true;
213    }
214    // 10.0.0.0/8
215    if a == 10 {
216        return true;
217    }
218    // 100.64.0.0/10 (CGNAT)
219    if a == 100 && (64..=127).contains(&b) {
220        return true;
221    }
222    // 127.0.0.0/8 (loopback)
223    if a == 127 {
224        return true;
225    }
226    // 169.254.0.0/16 (link-local, incl. 169.254.169.254 cloud metadata)
227    if a == 169 && b == 254 {
228        return true;
229    }
230    // 172.16.0.0/12
231    if a == 172 && (16..=31).contains(&b) {
232        return true;
233    }
234    // 192.0.0.0/24 (IETF protocol assignments)
235    if a == 192 && b == 0 && c == 0 {
236        return true;
237    }
238    // 192.0.2.0/24 (TEST-NET-1)
239    if a == 192 && b == 0 && c == 2 {
240        return true;
241    }
242    // 192.88.99.0/24 (6to4 anycast relay, RFC 3068 / RFC 7526)
243    if a == 192 && b == 88 && c == 99 {
244        return true;
245    }
246    // 192.168.0.0/16
247    if a == 192 && b == 168 {
248        return true;
249    }
250    // 198.18.0.0/15 (benchmarking)
251    if a == 198 && (18..=19).contains(&b) {
252        return true;
253    }
254    // 198.51.100.0/24 (TEST-NET-2)
255    if a == 198 && b == 51 && c == 100 {
256        return true;
257    }
258    // 203.0.113.0/24 (TEST-NET-3)
259    if a == 203 && b == 0 && c == 113 {
260        return true;
261    }
262    // 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved, incl. 255.255.255.255)
263    if a >= 224 {
264        return true;
265    }
266    false
267}
268
269/// Extract any IPv4 address embedded in an IPv6 address via a transition
270/// mechanism, so the IPv4 SSRF policy can be re-applied to it:
271///
272/// - IPv4-mapped `::ffff:a.b.c.d`
273/// - deprecated IPv4-compatible `::a.b.c.d`
274/// - NAT64 well-known prefix `64:ff9b::/96` (RFC 6052) — e.g.
275///   `64:ff9b::a9fe:a9fe` decodes to `169.254.169.254`
276/// - 6to4 `2002::/16` (RFC 3056) — e.g. `2002:a9fe:a9fe::` embeds
277///   `169.254.169.254`
278/// - SIIT "IPv4-translated" prefix `::ffff:0:0:0/96` (RFC 6052) — e.g.
279///   `::ffff:0:169.254.169.254` decodes to `169.254.169.254`. Note this is a
280///   DIFFERENT segment layout from IPv4-mapped `::ffff:0:0/96` (here
281///   `segments()[4] == 0xffff && segments()[5] == 0`, whereas IPv4-mapped has
282///   `segments()[5] == 0xffff`), so it is NOT caught by `to_ipv4_mapped()`.
283///
284/// Returns `None` for a genuinely-native IPv6 address (no embedded v4). Using
285/// `octets()` avoids any lossy `u16 -> u8` casts.
286fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
287    if let Some(v4) = ip.to_ipv4_mapped() {
288        return Some(v4);
289    }
290    let segs = ip.segments();
291    // SIIT IPv4-translated `::ffff:0:0:0/96` (RFC 6052): 64 zero bits, then
292    // 0xffff, then 16 zero bits, then the IPv4 in the last 32 bits. Distinct
293    // from IPv4-mapped `::ffff:0:0/96` (segment[5] == 0xffff) — here
294    // segment[4] == 0xffff and segment[5] == 0 — so `to_ipv4_mapped()` above
295    // does NOT catch it. Decode it so the embedded IPv4 is re-checked by the
296    // IPv4 policy (e.g. `::ffff:0:169.254.169.254` must be blocked).
297    if segs[0] == 0
298        && segs[1] == 0
299        && segs[2] == 0
300        && segs[3] == 0
301        && segs[4] == 0xffff
302        && segs[5] == 0
303    {
304        let o = ip.octets();
305        return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
306    }
307    let o = ip.octets();
308    // NAT64 64:ff9b::/96 — embedded IPv4 in the last 32 bits (octets 12..16),
309    // with octets 4..12 all zero.
310    if o[0] == 0x00
311        && o[1] == 0x64
312        && o[2] == 0xff
313        && o[3] == 0x9b
314        && o[4..12].iter().all(|&b| b == 0)
315    {
316        return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
317    }
318    // 6to4 2002::/16 — embedded IPv4 in bits 16..48 (octets 2..6).
319    if o[0] == 0x20 && o[1] == 0x02 {
320        return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
321    }
322    // Deprecated IPv4-compatible ::a.b.c.d (upper 96 bits zero).
323    ip.to_ipv4()
324}
325
326fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
327    // Block any private / loopback / link-local / metadata IPv4 that is
328    // tunnelled inside this v6 address (IPv4-mapped, IPv4-compatible, NAT64,
329    // 6to4, or SIIT IPv4-translated `::ffff:0:0:0/96`) by re-running the IPv4
330    // policy on the embedded address. A public
331    // embedded IPv4 (e.g. 6to4 `2002:0808:0808::` == 8.8.8.8) is NOT blocked
332    // here — it falls through to the native v6 range checks below.
333    if let Some(v4) = embedded_ipv4(ip)
334        && is_blocked_ipv4(v4)
335    {
336        return true;
337    }
338
339    let segs = ip.segments();
340    // RFC 8215 local-use NAT64 prefix `64:ff9b:1::/48`: deny the whole prefix
341    // outright. Unlike the well-known `64:ff9b::/96` (where a public embedded
342    // IPv4 like 8.8.8.8 is legitimately allowed), this is a private/site-local
343    // NAT64 allocation with no legitimate public destination, and the RFC 6052
344    // embedding position varies with prefix length — a blanket deny is both
345    // simpler and strictly safer (e.g. `64:ff9b:1::a9fe:a9fe` == 169.254.169.254).
346    if segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0x0001 {
347        return true;
348    }
349    // :: (unspecified)
350    if ip == Ipv6Addr::UNSPECIFIED {
351        return true;
352    }
353    // ::1 (loopback)
354    if ip == Ipv6Addr::LOCALHOST {
355        return true;
356    }
357    // fc00::/7 (unique local address)
358    if segs[0] & 0xfe00 == 0xfc00 {
359        return true;
360    }
361    // fe80::/10 (link-local)
362    if segs[0] & 0xffc0 == 0xfe80 {
363        return true;
364    }
365    // fec0::/10 (deprecated site-local — defence-in-depth)
366    if segs[0] & 0xffc0 == 0xfec0 {
367        return true;
368    }
369    // ff00::/8 (multicast)
370    if segs[0] & 0xff00 == 0xff00 {
371        return true;
372    }
373    // 2001:db8::/32 (documentation)
374    if segs[0] == 0x2001 && segs[1] == 0x0db8 {
375        return true;
376    }
377
378    // Remaining IANA IPv6 special-purpose prefixes with Globally Reachable =
379    // False. These are reserved / benchmarking / documentation / discard ranges
380    // that must never be dialled, matching the deny-list's reserved policy.
381    // Each check uses explicit masks so it cannot catch an adjacent *public*
382    // address (e.g. benchmarking 2001:2::/48 must not swallow Teredo 2001:0::/32
383    // where s[1] == 0, and documentation 2001:db8::/32 stays its own check).
384    //
385    //   Prefix               RFC        Purpose
386    //   100::/64             RFC 6666   Discard-Only address block
387    //   2001:2::/48          RFC 5180   Benchmarking
388    //   2001:10::/28         RFC 4843   ORCHID (deprecated)
389    //   2001:20::/28         RFC 7343   ORCHIDv2
390    //   3fff::/20            RFC 9637   Documentation
391    //   5f00::/16            RFC 9602   Segment Routing (SRv6) SIDs
392    //   2620:4f:8000::/48    RFC 7534   Direct Delegation AS112 service
393    let s = segs;
394    // 100::/64 (Discard-Only, RFC 6666)
395    if s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0 {
396        return true;
397    }
398    // 2001:2::/48 (Benchmarking, RFC 5180)
399    if s[0] == 0x2001 && s[1] == 0x0002 && s[2] == 0x0000 {
400        return true;
401    }
402    // 2001:10::/28 (ORCHID, deprecated RFC 4843)
403    if s[0] == 0x2001 && (s[1] & 0xFFF0) == 0x0010 {
404        return true;
405    }
406    // 2001:20::/28 (ORCHIDv2, RFC 7343)
407    if s[0] == 0x2001 && (s[1] & 0xFFF0) == 0x0020 {
408        return true;
409    }
410    // 3fff::/20 (Documentation, RFC 9637)
411    if s[0] == 0x3fff && (s[1] & 0xF000) == 0x0000 {
412        return true;
413    }
414    // 5f00::/16 (SRv6 SIDs, RFC 9602)
415    if s[0] == 0x5f00 {
416        return true;
417    }
418    // 2620:4f:8000::/48 (Direct Delegation AS112, RFC 7534)
419    if s[0] == 0x2620 && s[1] == 0x004f && s[2] == 0x8000 {
420        return true;
421    }
422
423    false
424}
425
426// ── RetryPolicy ──────────────────────────────────────────────────────────────
427
428/// Retry configuration for a [`RequestBuilder`].
429#[derive(Clone, Debug)]
430pub struct RetryPolicy {
431    /// Maximum number of additional attempts after the first failure.  Zero
432    /// means no retries (one attempt total).
433    pub max_retries: u32,
434    /// When `true` (the default), only GET / HEAD / PUT / DELETE / OPTIONS /
435    /// TRACE are retried; POST and PATCH are not.
436    pub retry_idempotent_only: bool,
437    /// Maximum Retry-After sleep duration to accept before clamping.
438    pub max_retry_after: Duration,
439    /// Per-request timeout.
440    pub request_timeout: Option<Duration>,
441}
442
443impl Default for RetryPolicy {
444    fn default() -> Self {
445        Self {
446            max_retries: 3,
447            retry_idempotent_only: true,
448            max_retry_after: Duration::from_secs(10),
449            request_timeout: Some(Duration::from_secs(30)),
450        }
451    }
452}
453
454// ── MockRegistry ─────────────────────────────────────────────────────────────
455
456/// Internal mock entry stored by [`MockRegistry`].
457pub(crate) struct MockEntry {
458    pub(crate) method: Option<Method>,
459    /// URL path to match against the path component of the outbound URL.
460    pub(crate) path: String,
461    /// Optional alias that must match the `Client`'s alias.
462    pub(crate) alias: Option<String>,
463    pub(crate) status: u16,
464    pub(crate) body: Option<serde_json::Value>,
465    pub(crate) call_count: Arc<AtomicUsize>,
466}
467
468/// Canned response returned by a [`MockRegistry`] match.
469pub(crate) struct MockResponse {
470    pub(crate) status: u16,
471    pub(crate) body: Option<serde_json::Value>,
472}
473
474/// In-process mock registry used by [`TestApp::http_mock`](crate::test::TestApp::http_mock).
475///
476/// Stored in [`AppState`](crate::AppState) extensions during test builds so
477/// that any [`Client`] extracted from state will intercept matching requests
478/// and return canned responses without hitting the network.
479pub struct MockRegistry {
480    entries: Mutex<Vec<MockEntry>>,
481}
482
483impl MockRegistry {
484    /// Create an empty registry.
485    #[must_use]
486    pub const fn new() -> Self {
487        Self {
488            entries: Mutex::new(Vec::new()),
489        }
490    }
491
492    /// Register a new mock entry.
493    pub(crate) fn register(&self, entry: MockEntry) {
494        self.entries
495            .lock()
496            .expect("mock registry lock poisoned")
497            .push(entry);
498    }
499
500    /// Find the first entry matching `(method, url, alias)` and increment its
501    /// call counter.  Returns `None` when no entry matches.
502    pub(crate) fn find_match(
503        &self,
504        method: &Method,
505        url: &str,
506        alias: Option<&str>,
507    ) -> Option<MockResponse> {
508        // Extract the URL path component for matching, stripping query and fragment.
509        // For full URLs (https://…) reqwest::Url::parse gives us the clean path.
510        // For relative paths we strip manually so "?query" doesn't break matching.
511        // Extract the path without query/fragment. For full URLs reqwest parses
512        // cleanly; for relative strings we strip manually.
513        let url_path_owned: String = reqwest::Url::parse(url).map_or_else(
514            |_| {
515                let s = url.split_once('?').map_or(url, |(p, _)| p);
516                s.split_once('#').map_or(s, |(p, _)| p).to_owned()
517            },
518            |parsed| parsed.path().to_owned(),
519        );
520        let url_path = url_path_owned.as_str();
521
522        // Hold the lock only for the search; release before fetching metadata.
523        let found = {
524            let entries = self.entries.lock().expect("mock registry lock poisoned");
525            entries.iter().find_map(|entry| {
526                let method_ok = entry.method.as_ref().is_none_or(|m| m == method);
527                // Path match: exact equality OR suffix at a segment boundary.
528                // When the mock path starts with '/' the leading slash IS the
529                // segment separator, so a non-empty prefix is also valid
530                // (e.g. mock "/charges" matches URL path "/v1/charges").
531                let path_ok = url_path == entry.path.as_str()
532                    || url_path
533                        .strip_suffix(entry.path.as_str())
534                        .is_some_and(|prefix| {
535                            prefix.is_empty()
536                                || prefix.ends_with('/')
537                                || entry.path.starts_with('/')
538                        });
539                let alias_ok = entry
540                    .alias
541                    .as_deref()
542                    .is_none_or(|a| alias.is_some_and(|b| a == b));
543                if method_ok && path_ok && alias_ok {
544                    Some((entry.call_count.clone(), entry.status, entry.body.clone()))
545                } else {
546                    None
547                }
548            })
549        };
550
551        found.map(|(call_count, status, body)| {
552            call_count.fetch_add(1, Ordering::SeqCst);
553            MockResponse { status, body }
554        })
555    }
556}
557
558impl Default for MockRegistry {
559    fn default() -> Self {
560        Self::new()
561    }
562}
563
564/// Newtype stored in [`AppState`](crate::AppState) extensions so the
565/// `MockRegistry` `Arc` survives a `build()` without double-wrapping.
566pub struct HttpMockRegistryExt(pub Arc<MockRegistry>);
567
568/// Shared, process-wide `reqwest::Client` registered in [`AppState`] at server
569/// boot. Cloning is O(1) because `reqwest::Client` is internally `Arc`-backed,
570/// and the connection pool is preserved across the clone.
571///
572/// `timeout_secs` records the per-request timeout the inner client was built
573/// with.  [`Client::from_state`] compares this against the currently installed
574/// config so that a `state_initializer` that replaces `AutumnConfig` with a
575/// different timeout causes the stale inner to be discarded and a fresh client
576/// to be built from the new config instead.
577#[derive(Clone)]
578pub(crate) struct SharedReqwestClient {
579    pub(crate) client: reqwest::Client,
580    pub(crate) timeout_secs: u64,
581}
582
583/// Handle returned by
584/// [`MockSetupBuilder::respond_with`] that lets tests assert call counts.
585pub struct MockHandle {
586    alias: String,
587    method: String,
588    path: String,
589    call_count: Arc<AtomicUsize>,
590}
591
592impl MockHandle {
593    /// Assert that the mocked endpoint was called exactly `expected` times.
594    ///
595    /// # Panics
596    ///
597    /// Panics with a diagnostic message when the actual call count differs.
598    pub fn expect_called(&self, expected: usize) {
599        let actual = self.call_count.load(Ordering::SeqCst);
600        assert_eq!(
601            actual, expected,
602            "http mock for {} {} {} expected {} call(s) but got {}",
603            self.alias, self.method, self.path, expected, actual,
604        );
605    }
606
607    /// Return the raw call count without asserting.
608    #[must_use]
609    pub fn call_count(&self) -> usize {
610        self.call_count.load(Ordering::SeqCst)
611    }
612}
613
614/// Builder returned by [`TestApp::http_mock`](crate::test::TestApp::http_mock).
615///
616/// Chain a method call (`get`, `post`, …) and a path, then call
617/// [`respond_with`](Self::respond_with) to register the entry and obtain a
618/// [`MockHandle`] for later assertions.
619pub struct MockSetupBuilder {
620    pub(crate) registry: Arc<MockRegistry>,
621    pub(crate) alias: String,
622    pub(crate) method: Option<Method>,
623    pub(crate) path: Option<String>,
624}
625
626impl MockSetupBuilder {
627    /// Match `GET <path>`.
628    #[must_use]
629    pub fn get(mut self, path: &str) -> Self {
630        self.method = Some(Method::GET);
631        self.path = Some(path.to_owned());
632        self
633    }
634    /// Match `POST <path>`.
635    #[must_use]
636    pub fn post(mut self, path: &str) -> Self {
637        self.method = Some(Method::POST);
638        self.path = Some(path.to_owned());
639        self
640    }
641    /// Match `PUT <path>`.
642    #[must_use]
643    pub fn put(mut self, path: &str) -> Self {
644        self.method = Some(Method::PUT);
645        self.path = Some(path.to_owned());
646        self
647    }
648    /// Match `PATCH <path>`.
649    #[must_use]
650    pub fn patch(mut self, path: &str) -> Self {
651        self.method = Some(Method::PATCH);
652        self.path = Some(path.to_owned());
653        self
654    }
655    /// Match `DELETE <path>`.
656    #[must_use]
657    pub fn delete(mut self, path: &str) -> Self {
658        self.method = Some(Method::DELETE);
659        self.path = Some(path.to_owned());
660        self
661    }
662
663    /// Match `HEAD <path>`.
664    #[must_use]
665    pub fn head(mut self, path: &str) -> Self {
666        self.method = Some(Method::HEAD);
667        self.path = Some(path.to_owned());
668        self
669    }
670
671    /// Register the mock entry and return a [`MockHandle`] for assertions.
672    ///
673    /// `status` is the HTTP status code to return.
674    /// `body` is serialised as JSON and returned as the response body.
675    #[must_use]
676    pub fn respond_with(self, status: u16, body: serde_json::Value) -> MockHandle {
677        let path = self.path.clone().unwrap_or_default();
678        let method_str = self
679            .method
680            .as_ref()
681            .map_or_else(|| "*".to_owned(), ToString::to_string);
682        let call_count = Arc::new(AtomicUsize::new(0));
683
684        self.registry.register(MockEntry {
685            method: self.method,
686            path: path.clone(),
687            alias: Some(self.alias.clone()),
688            status,
689            body: Some(body),
690            call_count: call_count.clone(),
691        });
692
693        MockHandle {
694            alias: self.alias,
695            method: method_str,
696            path,
697            call_count,
698        }
699    }
700
701    /// Convenience variant that returns the given status with an empty body.
702    ///
703    /// Unlike [`respond_with`](Self::respond_with), this stores `body: None` so
704    /// the mock response truly has zero body bytes (not the JSON literal `null`).
705    #[must_use]
706    pub fn respond_with_status(self, status: u16) -> MockHandle {
707        let path = self.path.clone().unwrap_or_default();
708        let method_str = self
709            .method
710            .as_ref()
711            .map_or_else(|| "*".to_owned(), ToString::to_string);
712        let call_count = Arc::new(AtomicUsize::new(0));
713
714        self.registry.register(MockEntry {
715            method: self.method,
716            path: path.clone(),
717            alias: Some(self.alias.clone()),
718            status,
719            body: None,
720            call_count: call_count.clone(),
721        });
722
723        MockHandle {
724            alias: self.alias,
725            method: method_str,
726            path,
727            call_count,
728        }
729    }
730}
731
732// ── Client ───────────────────────────────────────────────────────────────────
733
734/// Traced outbound HTTP client with automatic retries and test-mock support.
735///
736/// Extracted from `AppState` via Axum's extractor machinery — declare it as a
737/// handler parameter to get a pre-configured instance that respects
738/// `[http.client]` config and, in test builds, intercepts requests against any
739/// registered mocks.
740///
741/// ```rust,no_run
742/// use autumn_web::prelude::*;
743/// use autumn_web::http::Client;
744///
745/// #[get("/ping-upstream")]
746/// async fn ping(client: Client) -> AutumnResult<&'static str> {
747///     client.get("https://api.example.com/health").send().await?;
748///     Ok("ok")
749/// }
750/// ```
751///
752/// You can also construct a standalone client outside of a handler:
753///
754/// ```rust
755/// use autumn_web::http::Client;
756///
757/// let client = Client::new();
758/// ```
759#[derive(Clone)]
760pub struct Client {
761    inner: reqwest::Client,
762    /// Named alias — used to look up base URLs from config and to match mocks.
763    alias: Option<String>,
764    /// Base URL prepended to relative paths.
765    base_url: Option<String>,
766    /// Alias → base URL map loaded from `[http.client.base_urls]` config.
767    base_urls: HashMap<String, String>,
768    retry_policy: RetryPolicy,
769    /// When present (test builds), matching requests bypass the network.
770    mock: Option<Arc<MockRegistry>>,
771    /// Resilience configuration for circuit breakers.
772    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
773}
774
775impl Client {
776    /// Create a new client with default settings (30 s timeout, 3 retries on
777    /// idempotent methods).
778    #[must_use]
779    pub fn new() -> Self {
780        Self::with_timeout(Duration::from_secs(30))
781    }
782
783    /// Create a client with a custom per-request timeout.
784    ///
785    /// # Panics
786    ///
787    /// Panics if the underlying TLS backend cannot be initialised (should not
788    /// happen with the default `rustls-tls` feature).
789    #[must_use]
790    pub fn with_timeout(timeout: Duration) -> Self {
791        let inner = reqwest::ClientBuilder::new()
792            .timeout(timeout)
793            .build()
794            .expect("failed to build reqwest client");
795        Self {
796            inner,
797            alias: None,
798            base_url: None,
799            base_urls: HashMap::new(),
800            retry_policy: RetryPolicy {
801                max_retries: 3,
802                retry_idempotent_only: true,
803                max_retry_after: Duration::from_secs(10),
804                request_timeout: Some(timeout),
805            },
806            mock: None,
807            resilience_config: None,
808        }
809    }
810
811    /// Build a bare `reqwest::Client` from `[http.client]` config.
812    ///
813    /// Used by `build_state` to create the single shared instance registered
814    /// in `AppState` at server boot, and as the fallback when no shared client
815    /// is available.
816    ///
817    /// # Panics
818    ///
819    /// Panics if the underlying TLS backend cannot be initialised.
820    pub(crate) fn build_inner(config: &crate::config::HttpClientConfig) -> reqwest::Client {
821        reqwest::ClientBuilder::new()
822            .timeout(Duration::from_secs(config.timeout_secs))
823            .build()
824            .expect("failed to build reqwest client")
825    }
826
827    /// Assemble a `Client` around an already-built `reqwest::Client` using the
828    /// policy fields from `config`.  The caller supplies the inner client so
829    /// the connection pool can be shared across requests.
830    fn from_config_with_inner(
831        inner: reqwest::Client,
832        config: &crate::config::HttpClientConfig,
833    ) -> Self {
834        let timeout = Duration::from_secs(config.timeout_secs);
835        Self {
836            inner,
837            alias: None,
838            base_url: None,
839            base_urls: config.base_urls.clone(),
840            retry_policy: RetryPolicy {
841                max_retries: config.max_retries,
842                retry_idempotent_only: true,
843                max_retry_after: Duration::from_secs(config.max_retry_after_secs),
844                request_timeout: Some(timeout),
845            },
846            mock: None,
847            resilience_config: None,
848        }
849    }
850
851    /// Assemble a `Client` with default policy around an already-built
852    /// `reqwest::Client`.  Used when a shared inner client is available but
853    /// no explicit `[http.client]` config is registered.
854    fn with_inner(inner: reqwest::Client) -> Self {
855        Self {
856            inner,
857            alias: None,
858            base_url: None,
859            base_urls: HashMap::new(),
860            retry_policy: RetryPolicy::default(),
861            mock: None,
862            resilience_config: None,
863        }
864    }
865
866    /// Create a client from `[http.client]` framework configuration.
867    ///
868    /// # Panics
869    ///
870    /// Panics if the underlying TLS backend cannot be initialised (should not
871    /// happen with the default `rustls-tls` feature).
872    #[must_use]
873    pub fn from_config(config: &crate::config::HttpClientConfig) -> Self {
874        Self::from_config_with_inner(Self::build_inner(config), config)
875    }
876
877    /// Attach a mock registry (used by the test harness).
878    pub(crate) fn with_mock(mut self, registry: Arc<MockRegistry>) -> Self {
879        self.mock = Some(registry);
880        self
881    }
882
883    /// Build a client from runtime application state.
884    ///
885    /// When the server was started via `AppBuilder`, a single `reqwest::Client`
886    /// is registered in `AppState` at boot as a `SharedReqwestClient`.  This
887    /// method clones that shared instance (O(1), preserves the connection pool)
888    /// instead of constructing a new one, eliminating per-request TCP/TLS
889    /// handshakes and DNS-resolver-spawn overhead.
890    ///
891    /// Falls back to `Self::new()` for detached or test state that does not
892    /// carry a shared client.
893    #[must_use]
894    pub fn from_state(state: &crate::AppState) -> Self {
895        let autumn_config = state.extension::<crate::config::AutumnConfig>();
896        let config = state
897            .extension::<crate::config::HttpConfig>()
898            .or_else(|| autumn_config.as_ref().map(|c| Arc::new(c.http.clone())));
899
900        // Only reuse the shared inner client when its baked-in timeout still
901        // matches the effective config timeout.  A state_initializer that
902        // replaces AutumnConfig/HttpConfig with a different timeout_secs runs
903        // after build_state, so without this check the stale inner would
904        // silently override the new config's per-request timeout.
905        let effective_timeout_secs = config.as_ref().map_or_else(
906            || crate::config::HttpClientConfig::default().timeout_secs,
907            |c| c.client.timeout_secs,
908        );
909        let shared = state.extension::<SharedReqwestClient>().and_then(|s| {
910            if s.timeout_secs == effective_timeout_secs {
911                Some(s.client.clone())
912            } else {
913                None
914            }
915        });
916
917        let mut client = match (config, shared) {
918            (Some(cfg), Some(inner)) => Self::from_config_with_inner(inner, &cfg.client),
919            (Some(cfg), None) => Self::from_config(&cfg.client),
920            (None, Some(inner)) => Self::with_inner(inner),
921            (None, None) => Self::new(),
922        };
923
924        client.resilience_config = autumn_config.map(|c| Arc::new(c.resilience.clone()));
925
926        if let Some(ext) = state.extension::<HttpMockRegistryExt>() {
927            client = client.with_mock(ext.0.clone());
928        }
929
930        client
931    }
932
933    /// Return a clone of this client scoped to the named alias.
934    ///
935    /// When a `[http.client.base_urls]` entry exists for the alias the client
936    /// will prepend that URL to all relative paths. Mocks registered for the
937    /// alias via [`TestApp::http_mock`](crate::test::TestApp::http_mock) will
938    /// match requests made through this named client.
939    #[must_use]
940    pub fn named(&self, alias: &str) -> Self {
941        let base_url = self
942            .base_urls
943            .get(alias)
944            .cloned()
945            .or_else(|| self.base_url.clone());
946        Self {
947            inner: self.inner.clone(),
948            alias: Some(alias.to_owned()),
949            base_url,
950            base_urls: self.base_urls.clone(),
951            retry_policy: self.retry_policy.clone(),
952            mock: self.mock.clone(),
953            resilience_config: self.resilience_config.clone(),
954        }
955    }
956
957    /// Set (or override) the base URL prepended to relative request paths.
958    #[must_use]
959    pub fn with_base_url(&self, base_url: impl Into<String>) -> Self {
960        Self {
961            inner: self.inner.clone(),
962            alias: self.alias.clone(),
963            base_url: Some(base_url.into()),
964            base_urls: self.base_urls.clone(),
965            retry_policy: self.retry_policy.clone(),
966            mock: self.mock.clone(),
967            resilience_config: self.resilience_config.clone(),
968        }
969    }
970
971    fn build_request(&self, method: Method, url: impl AsRef<str>) -> RequestBuilder {
972        let url_str = url.as_ref();
973        let full_url = if url_str.starts_with("http://") || url_str.starts_with("https://") {
974            url_str.to_owned()
975        } else if let Some(base) = &self.base_url {
976            format!(
977                "{}/{}",
978                base.trim_end_matches('/'),
979                url_str.trim_start_matches('/')
980            )
981        } else {
982            url_str.to_owned()
983        };
984
985        RequestBuilder {
986            client: self.inner.clone(),
987            method,
988            url: full_url,
989            extra_headers: HeaderMap::new(),
990            body: None,
991            retry_policy: self.retry_policy.clone(),
992            mock: self.mock.clone(),
993            alias: self.alias.clone(),
994            pending_error: None,
995            resilience_config: self.resilience_config.clone(),
996            redirect_mode: RedirectMode::Default,
997            pin_addr: None,
998            ssrf_safe: false,
999        }
1000    }
1001
1002    /// Build a `GET` request.
1003    #[must_use]
1004    pub fn get(&self, url: impl AsRef<str>) -> RequestBuilder {
1005        self.build_request(Method::GET, url)
1006    }
1007    /// Build a `POST` request.
1008    #[must_use]
1009    pub fn post(&self, url: impl AsRef<str>) -> RequestBuilder {
1010        self.build_request(Method::POST, url)
1011    }
1012    /// Build a `PUT` request.
1013    #[must_use]
1014    pub fn put(&self, url: impl AsRef<str>) -> RequestBuilder {
1015        self.build_request(Method::PUT, url)
1016    }
1017    /// Build a `PATCH` request.
1018    #[must_use]
1019    pub fn patch(&self, url: impl AsRef<str>) -> RequestBuilder {
1020        self.build_request(Method::PATCH, url)
1021    }
1022    /// Build a `DELETE` request.
1023    #[must_use]
1024    pub fn delete(&self, url: impl AsRef<str>) -> RequestBuilder {
1025        self.build_request(Method::DELETE, url)
1026    }
1027
1028    /// Build a `HEAD` request.
1029    #[must_use]
1030    pub fn head(&self, url: impl AsRef<str>) -> RequestBuilder {
1031        self.build_request(Method::HEAD, url)
1032    }
1033
1034    /// Build an SSRF-safe `GET` request.
1035    ///
1036    /// This is the composed safe path for fetching **untrusted** URLs. Before
1037    /// connecting it resolves the host **once**, validates every resolved IP
1038    /// against the built-in SSRF deny-list ([`is_blocked_ip`]) and rejects the
1039    /// request if *any* address is blocked. The connection is then pinned to the
1040    /// full set of validated addresses so reqwest cannot re-resolve the host
1041    /// (closing the DNS-rebinding / TOCTOU window) yet can still fall back across
1042    /// them in order if the first is unreachable. Redirects are followed manually up to
1043    /// `SSRF_SAFE_MAX_REDIRECTS` hops, re-running resolve→validate→pin on each
1044    /// hop; a hop that downgrades the scheme from `https` to `http` is rejected
1045    /// as defence-in-depth.
1046    ///
1047    /// The redirect count and per-hop validation honour a chained builder
1048    /// override (the built-in resolve→validate→pin and scheme-downgrade guards
1049    /// always apply):
1050    ///
1051    /// - by default, up to `SSRF_SAFE_MAX_REDIRECTS` hops are followed;
1052    /// - a chained [`no_redirect`](RequestBuilder::no_redirect) returns the
1053    ///   initial `3xx` verbatim — the initial URL is still resolved, validated
1054    ///   and pinned, but no redirect is followed;
1055    /// - a chained [`follow_redirects(max, validator)`](RequestBuilder::follow_redirects)
1056    ///   caps following at `max` hops (so `max == 0` turns the first `3xx` into
1057    ///   [`ClientError::TooManyRedirects`]) and additionally runs the caller's
1058    ///   `validator(&next)` on every hop, on top of the built-in guards.
1059    ///
1060    /// Like the test-mock path, this custom send path bypasses the process-wide
1061    /// circuit-breaker registry to avoid entangling per-URL SSRF fetches with
1062    /// the shared per-host breaker state.
1063    ///
1064    /// **Env proxies are bypassed.** Each pinned per-hop client is built with
1065    /// `.no_proxy()`, so `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` are ignored
1066    /// and the socket connects directly to the validated/pinned address. This is
1067    /// required for the pin to hold: reqwest checks proxy interception before the
1068    /// connector where the `resolve()` override applies, so a configured proxy
1069    /// would otherwise receive the request and re-resolve the host — reopening
1070    /// the DNS-rebinding / SSRF window this API closes.
1071    ///
1072    /// **Cannot be combined with [`pin_to`](RequestBuilder::pin_to).** This path
1073    /// performs its own per-hop resolve→validate→pin and never reads the
1074    /// `pin_to` address, so an explicit pin would be silently ignored. Chaining
1075    /// the two is therefore rejected at send time with
1076    /// [`ClientError::PinNotAllowedWithSsrfSafe`]. Use `pin_to` alone for a
1077    /// caller-chosen fixed address, or `get_ssrf_safe` alone for guarded
1078    /// automatic per-hop pinning.
1079    #[must_use]
1080    pub fn get_ssrf_safe(&self, url: impl Into<String>) -> RequestBuilder {
1081        let mut builder = self.build_request(Method::GET, url.into());
1082        builder.ssrf_safe = true;
1083        builder
1084    }
1085}
1086
1087impl Default for Client {
1088    fn default() -> Self {
1089        Self::new()
1090    }
1091}
1092
1093impl axum::extract::FromRequestParts<crate::AppState> for Client {
1094    type Rejection = std::convert::Infallible;
1095
1096    async fn from_request_parts(
1097        _parts: &mut http::request::Parts,
1098        state: &crate::AppState,
1099    ) -> Result<Self, std::convert::Infallible> {
1100        Ok(Self::from_state(state))
1101    }
1102}
1103
1104// ── RequestBuilder ───────────────────────────────────────────────────────────
1105
1106/// Type alias for a redirect-`Location` validator.
1107type RedirectValidator = Arc<dyn Fn(&str) -> bool + Send + Sync>;
1108
1109/// Per-request redirect handling.
1110///
1111/// `Default` preserves the historical behaviour exactly (the shared-client fast
1112/// path with reqwest's built-in auto-follow). `None` and `Follow` route the
1113/// request through the custom one-shot-client send path.
1114enum RedirectMode {
1115    /// Historical behaviour: shared client, reqwest auto-follows up to 10 hops.
1116    Default,
1117    /// Never follow: a 3xx is returned to the caller verbatim.
1118    None,
1119    /// Follow up to `max` hops, calling `validator` on each absolute target
1120    /// before following it.
1121    Follow {
1122        max: usize,
1123        validator: RedirectValidator,
1124    },
1125}
1126
1127/// Default hop cap for the composed [`Client::get_ssrf_safe`] safe path.
1128const SSRF_SAFE_MAX_REDIRECTS: usize = 5;
1129
1130/// Fluent outbound request builder produced by [`Client`] methods.
1131pub struct RequestBuilder {
1132    client: reqwest::Client,
1133    method: Method,
1134    url: String,
1135    extra_headers: HeaderMap,
1136    /// Request body. `Bytes` gives O(1) clones across retry attempts.
1137    body: Option<Bytes>,
1138    retry_policy: RetryPolicy,
1139    mock: Option<Arc<MockRegistry>>,
1140    alias: Option<String>,
1141    /// Captures errors from `json()` or invalid headers to surface in `send()`.
1142    pending_error: Option<ClientError>,
1143    /// Resilience configuration for circuit breakers.
1144    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
1145    /// Per-request redirect handling (see [`RedirectMode`]).
1146    redirect_mode: RedirectMode,
1147    /// When set, connect directly to this socket, skipping DNS resolution while
1148    /// preserving the original `Host` header + SNI. See [`RequestBuilder::pin_to`].
1149    pin_addr: Option<SocketAddr>,
1150    /// When `true`, use the composed SSRF-safe send path (resolve→validate→pin
1151    /// with per-hop redirect validation). Set by [`Client::get_ssrf_safe`].
1152    ssrf_safe: bool,
1153}
1154
1155impl RequestBuilder {
1156    /// Append a request header.
1157    ///
1158    /// Headers named `authorization`, `cookie`, or `set-cookie` are accepted
1159    /// normally but are **redacted** in tracing events and log output.
1160    /// Invalid header names or values emit a `tracing::warn!` and are skipped.
1161    #[must_use]
1162    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
1163        let name_str = name.as_ref();
1164        let value_str = value.as_ref();
1165        match (
1166            HeaderName::from_bytes(name_str.as_bytes()),
1167            HeaderValue::from_str(value_str),
1168        ) {
1169            (Ok(n), Ok(v)) => {
1170                self.extra_headers.insert(n, v);
1171            }
1172            (Err(e), _) => {
1173                tracing::warn!(header.name = name_str, error = %e, "invalid header name — header skipped");
1174            }
1175            (_, Err(e)) => {
1176                tracing::warn!(header.name = name_str, error = %e, "invalid header value — header skipped");
1177            }
1178        }
1179        self
1180    }
1181
1182    /// Serialise `body` as JSON and set `Content-Type: application/json`.
1183    ///
1184    /// Serialisation errors are captured and returned when [`send`](Self::send)
1185    /// is called rather than being silently discarded.
1186    #[must_use]
1187    pub fn json<T: Serialize>(mut self, body: &T) -> Self {
1188        match serde_json::to_vec(body) {
1189            Ok(bytes) => {
1190                self.body = Some(Bytes::from(bytes));
1191                self = self.header("content-type", "application/json");
1192            }
1193            Err(e) => {
1194                self.pending_error = Some(ClientError::Json(e));
1195            }
1196        }
1197        self
1198    }
1199
1200    /// Set a plain-text body.
1201    #[must_use]
1202    pub fn text_body(mut self, body: impl Into<String>) -> Self {
1203        self.body = Some(Bytes::from(body.into().into_bytes()));
1204        self
1205    }
1206
1207    /// Override the maximum retry count for this request.
1208    ///
1209    /// Also clears the idempotent-only flag so non-idempotent methods such as
1210    /// `POST` and `PATCH` are retried when the caller explicitly requests it.
1211    #[must_use]
1212    pub const fn retries(mut self, max: u32) -> Self {
1213        self.retry_policy.max_retries = max;
1214        self.retry_policy.retry_idempotent_only = false;
1215        self
1216    }
1217
1218    /// Override the maximum `Retry-After` sleep duration for this request.
1219    #[must_use]
1220    pub const fn max_retry_after(mut self, max: Duration) -> Self {
1221        self.retry_policy.max_retry_after = max;
1222        self
1223    }
1224
1225    /// Disable retries for this request.
1226    #[must_use]
1227    pub const fn no_retry(mut self) -> Self {
1228        self.retry_policy.max_retries = 0;
1229        self
1230    }
1231
1232    /// Disable redirect following for this request.
1233    ///
1234    /// A `3xx` response is returned to the caller verbatim (status, headers and
1235    /// body) rather than being followed. Routes the request through the custom
1236    /// one-shot-client send path, which bypasses the process-wide circuit
1237    /// breaker.
1238    #[must_use]
1239    pub fn no_redirect(mut self) -> Self {
1240        self.redirect_mode = RedirectMode::None;
1241        self
1242    }
1243
1244    /// Follow up to `max` redirects, validating each hop before following it.
1245    ///
1246    /// Before following a `3xx`, the `Location` header is resolved to an
1247    /// absolute URL (relative locations are joined against the current URL) and
1248    /// `validator(&absolute_location)` is called. If it returns `false` the
1249    /// request fails with [`ClientError::RedirectRejected`]. If the chain would
1250    /// exceed `max` hops the request fails with
1251    /// [`ClientError::TooManyRedirects`] (so `max == 0` turns the first `3xx`
1252    /// into an error). Routes the request through the custom one-shot-client
1253    /// send path, which bypasses the process-wide circuit breaker.
1254    ///
1255    /// **TOCTOU / rebinding limitation.** The `validator` receives the redirect
1256    /// target as a *string* (not a resolved IP), and the subsequent connection
1257    /// re-resolves that host via normal DNS. So a validator that inspects IP
1258    /// literals sees only literal hosts, has a connect-time TOCTOU window
1259    /// against a hostname that re-resolves between check and connect, and
1260    /// provides no address pinning. When you need pinned, rebind-safe following
1261    /// that validates every resolved IP and pins the connection per hop, use
1262    /// [`Client::get_ssrf_safe`] instead.
1263    #[must_use]
1264    pub fn follow_redirects<F>(mut self, max: usize, validator: F) -> Self
1265    where
1266        F: Fn(&str) -> bool + Send + Sync + 'static,
1267    {
1268        self.redirect_mode = RedirectMode::Follow {
1269            max,
1270            validator: Arc::new(validator),
1271        };
1272        self
1273    }
1274
1275    /// Pin the connection to `addr`, skipping DNS resolution.
1276    ///
1277    /// The original `Host` header and TLS SNI are preserved; only the
1278    /// address the socket connects to is overridden. This protects against
1279    /// DNS-rebinding / TOCTOU attacks where a hostname re-resolves to a
1280    /// different (private) address between validation and connection.
1281    ///
1282    /// **Requires a domain (hostname) URL host.** The pin is enforced via a DNS
1283    /// `resolve` override, but reqwest/hyper treat an IP-literal URL host as
1284    /// already-resolved and never consult the resolver — so the override is
1285    /// skipped and the socket would connect to the literal in the URL, not the
1286    /// pinned address. A `pin_to` request whose URL host is an IP literal
1287    /// (IPv4 or IPv6) is therefore **rejected at send time** with
1288    /// [`ClientError::PinRequiresDomainHost`]. Put the desired IP directly in the
1289    /// URL (no pin needed), or use a domain host.
1290    ///
1291    /// **Pinning applies to the initial connection only.** Redirects are **not**
1292    /// followed under `pin_to`: a `3xx` is returned to the caller verbatim (as if
1293    /// [`no_redirect`](Self::no_redirect) were set) rather than being
1294    /// auto-followed to a possibly-different host that would be re-resolved via
1295    /// normal DNS, silently escaping the pin. If you need pinned, rebind-safe
1296    /// per-hop following, use [`Client::get_ssrf_safe`] instead.
1297    ///
1298    /// **Cannot be combined with [`follow_redirects`](Self::follow_redirects).**
1299    /// Because the pin only covers the first hop while later hops would re-resolve
1300    /// via DNS, chaining `pin_to` with `follow_redirects` (in either order) is
1301    /// rejected at send time with [`ClientError::IncompatiblePinRedirect`] rather
1302    /// than silently following a redirect off the pinned address. Use
1303    /// [`Client::get_ssrf_safe`] for pinned, per-hop-revalidated redirect
1304    /// following, `pin_to` alone (which returns the `3xx` unfollowed), or
1305    /// `follow_redirects` without `pin_to`.
1306    ///
1307    /// Implemented via a one-shot `reqwest::ClientBuilder::resolve(host, addr)`
1308    /// scoped to this request. Note that reqwest ignores the **port** in the
1309    /// resolve override and connects to the port from the request URL, so
1310    /// `addr.port()` is only honoured when it matches the URL's port (which it
1311    /// does for addresses obtained by resolving that same URL). Routes the
1312    /// request through the custom one-shot-client send path, which bypasses the
1313    /// process-wide circuit breaker.
1314    ///
1315    /// **Env proxies are bypassed.** The pinned client is built with
1316    /// `.no_proxy()`, so `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` are ignored
1317    /// and the socket connects directly to `addr`. This is required for the pin
1318    /// to hold: reqwest checks proxy interception before the connector where the
1319    /// `resolve()` override applies, so a configured proxy would otherwise
1320    /// receive the request and re-resolve the host — defeating the pin.
1321    #[must_use]
1322    pub const fn pin_to(mut self, addr: SocketAddr) -> Self {
1323        self.pin_addr = Some(addr);
1324        self
1325    }
1326
1327    /// Send the request, applying retries and returning a [`Response`].
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns [`ClientError::Json`] if a prior `.json()` call failed to
1332    /// serialise the body.  Returns [`ClientError::Request`] for transport
1333    /// errors that exhaust all retry attempts.  Returns [`ClientError::NoMock`]
1334    /// if the request is made in a test context without a matching mock entry.
1335    ///
1336    /// # Panics
1337    ///
1338    /// Contains an internal `unreachable!()` that guards against a logic error
1339    /// in the retry loop; it cannot be reached in practice.
1340    pub async fn send(self) -> Result<Response, ClientError> {
1341        // Surface any error captured during builder construction.
1342        if let Some(err) = self.pending_error {
1343            return Err(err);
1344        }
1345
1346        // Bypassing circuit breaker if a mock registry is present.
1347        if self.mock.is_some() {
1348            return self.send_inner(false).await;
1349        }
1350
1351        // Custom send path: any of no_redirect / follow_redirects / pin_to /
1352        // get_ssrf_safe builds one-shot reqwest client(s) with Policy::none()
1353        // (+ optional .resolve()) and does manual redirect handling. Like the
1354        // mock path it deliberately BYPASSES the process-global circuit breaker
1355        // to avoid entangling these one-off, per-URL requests with the shared
1356        // per-host breaker registry.
1357        if self.needs_custom_path() {
1358            return self.send_custom().await;
1359        }
1360
1361        // ── Resilience / Circuit Breaker ──────────────────────────────────
1362        let host = url::Url::parse(&self.url).ok().map_or_else(
1363            || "unknown".to_owned(),
1364            |u| {
1365                let h = u.host_str().unwrap_or("unknown");
1366                u.port()
1367                    .map_or_else(|| h.to_owned(), |port| format!("{h}:{port}"))
1368            },
1369        );
1370
1371        let breaker = self.resilience_config.as_ref().map_or_else(
1372            || {
1373                crate::circuit_breaker::global_registry().get_or_create(
1374                    &host,
1375                    crate::circuit_breaker::CircuitBreakerPolicy::default(),
1376                )
1377            },
1378            |rc| {
1379                let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(rc, &host);
1380                crate::circuit_breaker::global_registry().get_or_create_with_config(&host, policy)
1381            },
1382        );
1383
1384        // Check if circuit breaker is open
1385        if breaker.before_call().is_err() {
1386            return Err(ClientError::CircuitBreakerOpen);
1387        }
1388        let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
1389
1390        let is_half_open = breaker.state() == crate::circuit_breaker::CircuitState::HalfOpen;
1391        let res = self.send_inner(is_half_open).await;
1392        match &res {
1393            Ok(resp) => {
1394                let success = resp.status().as_u16() < 500;
1395                if success {
1396                    guard.success();
1397                } else {
1398                    guard.failure();
1399                }
1400            }
1401            Err(_) => {
1402                guard.failure();
1403            }
1404        }
1405        res
1406    }
1407
1408    async fn send_inner(self, suppress_retries: bool) -> Result<Response, ClientError> {
1409        // ── Mock short-circuit ──────────────────────────────────────────────
1410        if let Some(ref mock) = self.mock {
1411            match mock.find_match(&self.method, &self.url, self.alias.as_deref()) {
1412                Some(mock_resp) => {
1413                    let status = reqwest::StatusCode::from_u16(mock_resp.status)
1414                        .unwrap_or(reqwest::StatusCode::OK);
1415                    let body_bytes = mock_resp
1416                        .body
1417                        .as_ref()
1418                        .map(|v| serde_json::to_vec(v).unwrap_or_default())
1419                        .unwrap_or_default();
1420
1421                    tracing::info!(
1422                        http.method = %self.method,
1423                        http.url = %self.url,
1424                        http.status = mock_resp.status,
1425                        "[mock] outbound request intercepted"
1426                    );
1427
1428                    return Ok(Response {
1429                        status,
1430                        headers: HeaderMap::new(),
1431                        body: Bytes::from(body_bytes),
1432                        url: None,
1433                    });
1434                }
1435                None => {
1436                    // A mock registry is present but nothing matched — treat as
1437                    // a test failure rather than falling through to the network.
1438                    return Err(ClientError::NoMock(
1439                        self.method.to_string(),
1440                        self.url.clone(),
1441                    ));
1442                }
1443            }
1444        }
1445
1446        // ── Real network request with retries ───────────────────────────────
1447        let start = Instant::now();
1448        let max_attempts = if suppress_retries {
1449            1
1450        } else if is_idempotent_method(&self.method) || !self.retry_policy.retry_idempotent_only {
1451            self.retry_policy.max_retries.saturating_add(1)
1452        } else {
1453            1
1454        };
1455
1456        for attempt in 0..max_attempts {
1457            if attempt > 0 {
1458                // Cap the exponent to prevent u64 overflow when max_retries is large.
1459                let exp = (attempt - 1).min(10);
1460                let delay = Duration::from_millis(100 * (1_u64 << exp));
1461                tokio::time::sleep(delay).await;
1462            }
1463
1464            let mut req = self.client.request(self.method.clone(), &self.url);
1465
1466            // Inject W3C trace context headers from the active span.
1467            req = inject_trace_context(req);
1468
1469            // Apply caller-supplied headers (may override or extend trace headers).
1470            for (name, value) in &self.extra_headers {
1471                req = req.header(name.clone(), value.clone());
1472            }
1473
1474            if let Some(body) = &self.body {
1475                req = req.body(body.clone());
1476            }
1477
1478            match req.send().await {
1479                Ok(resp) => {
1480                    let status = resp.status();
1481                    let headers = resp.headers().clone();
1482                    let url_used = resp.url().clone();
1483
1484                    // 429 → honour Retry-After and retry if attempts remain.
1485                    if status.as_u16() == 429 && attempt + 1 < max_attempts {
1486                        let mut sleep_delay =
1487                            parse_retry_after(&headers).unwrap_or(Duration::from_secs(1));
1488                        sleep_delay = sleep_delay.min(self.retry_policy.max_retry_after);
1489                        if let Some(req_timeout) = self.retry_policy.request_timeout {
1490                            sleep_delay = sleep_delay.min(req_timeout);
1491                        }
1492                        tokio::time::sleep(sleep_delay).await;
1493                        continue;
1494                    }
1495
1496                    // 5xx transient gateway errors → retry if attempts remain.
1497                    if is_retryable_status(status.as_u16()) && attempt + 1 < max_attempts {
1498                        continue;
1499                    }
1500
1501                    let body = resp
1502                        .bytes()
1503                        .await
1504                        .map_err(|e| ClientError::Request(e.without_url()))?;
1505                    let elapsed = start.elapsed();
1506                    log_request(
1507                        self.method.as_str(),
1508                        &url_used,
1509                        status.as_u16(),
1510                        elapsed,
1511                        &self.extra_headers,
1512                    );
1513
1514                    return Ok(Response {
1515                        status,
1516                        headers,
1517                        body,
1518                        url: Some(url_used),
1519                    });
1520                }
1521                // Only retry transient connect/timeout errors; non-transient errors
1522                // (e.g. malformed URL) fail immediately.
1523                Err(e) if (e.is_connect() || e.is_timeout()) && attempt + 1 < max_attempts => {}
1524                Err(e) => return Err(ClientError::Request(e.without_url())),
1525            }
1526        }
1527
1528        // The retry loop always returns inside the last attempt; this is unreachable.
1529        unreachable!("retry loop exited without returning a result — this is a bug")
1530    }
1531
1532    /// `true` when any security-hardening option requires the custom send path.
1533    const fn needs_custom_path(&self) -> bool {
1534        self.ssrf_safe
1535            || self.pin_addr.is_some()
1536            || !matches!(self.redirect_mode, RedirectMode::Default)
1537    }
1538
1539    /// Dispatch to the appropriate custom send path. Consumes `self`.
1540    async fn send_custom(self) -> Result<Response, ClientError> {
1541        // Reject the incompatible `get_ssrf_safe` + `pin_to` combination up
1542        // front — deterministically, before any network I/O. `get_ssrf_safe`
1543        // routes through `send_ssrf_safe`, which runs its OWN per-hop
1544        // resolve→validate→pin and never reads `self.pin_addr`; a caller's
1545        // explicit `pin_to(addr)` would therefore be silently ignored. Fail
1546        // loudly instead so the mismatch is caught rather than masked. Use
1547        // `pin_to` alone for a caller-chosen fixed address, or `get_ssrf_safe`
1548        // alone for guarded automatic per-hop pinning.
1549        if self.ssrf_safe && self.pin_addr.is_some() {
1550            return Err(ClientError::PinNotAllowedWithSsrfSafe(
1551                "get_ssrf_safe cannot be combined with pin_to: the SSRF-safe path \
1552                 performs its own per-hop resolve/validate/pin and never reads the \
1553                 pin_to address, so an explicit pin would be silently ignored. Use \
1554                 pin_to alone for a caller-chosen address, or get_ssrf_safe alone \
1555                 for guarded automatic per-hop pinning.",
1556            ));
1557        }
1558
1559        // Reject the incompatible `pin_to` + `follow_redirects` combination up
1560        // front — deterministically, before issuing any request. A single pinned
1561        // `SocketAddr` only applies to hop 0 of `follow_loop`; later hops resolve
1562        // via normal DNS, so following a cross-host `3xx` would silently escape
1563        // the pin and defeat its purpose. `get_ssrf_safe` never sets `pin_addr`,
1564        // so its per-hop resolve→validate→pin path is unaffected by this guard.
1565        if self.pin_addr.is_some() && matches!(self.redirect_mode, RedirectMode::Follow { .. }) {
1566            return Err(ClientError::IncompatiblePinRedirect(
1567                "pin_to cannot be combined with follow_redirects: the pin only \
1568                 covers the first hop and later redirect hops re-resolve via DNS, \
1569                 escaping the pin. Use get_ssrf_safe for pinned, per-hop-revalidated \
1570                 redirect following; pin_to alone (which returns the 3xx unfollowed); \
1571                 or follow_redirects without pin_to.",
1572            ));
1573        }
1574
1575        // Reject `pin_to` on an IP-literal URL host up front — deterministically,
1576        // before any network I/O. reqwest/hyper treat an IP-literal host as
1577        // already-resolved and do NOT consult the DNS resolver, so the
1578        // `resolve_to_addrs` override installed by `build_oneshot_client` (which
1579        // is what enforces the pin) is skipped and the socket connects to the
1580        // literal in the URL rather than the pinned address — silently violating
1581        // `pin_to`'s documented guarantee. `get_ssrf_safe` never sets `pin_addr`
1582        // (and validates+connects to the same literal, so it stays safe), so this
1583        // guard targets only the explicit `pin_to` primitive.
1584        if self.pin_addr.is_some() && url_host_is_ip_literal(&self.url)? {
1585            return Err(ClientError::PinRequiresDomainHost(
1586                "pin_to cannot be honored for an IP-literal URL host because the \
1587                 HTTP stack connects to the literal directly and skips the pinned \
1588                 address; put the desired IP directly in the URL, or use a domain host.",
1589            ));
1590        }
1591
1592        let timeout = self
1593            .retry_policy
1594            .request_timeout
1595            .unwrap_or_else(|| Duration::from_secs(30));
1596
1597        if self.ssrf_safe {
1598            return self.send_ssrf_safe(timeout).await;
1599        }
1600
1601        // Extract the follow parameters (ending the borrow) before moving `self`.
1602        let follow = match &self.redirect_mode {
1603            RedirectMode::Follow { max, validator } => Some((*max, validator.clone())),
1604            RedirectMode::None | RedirectMode::Default => None,
1605        };
1606        if let Some((max, validator)) = follow {
1607            return self.follow_loop(max, validator, timeout).await;
1608        }
1609
1610        // Only `RedirectMode::None` (explicit `no_redirect`) and the pin-only
1611        // `RedirectMode::Default` reach here (`Follow` and `ssrf_safe` returned
1612        // above). Both use `Policy::none()`: a 3xx is returned to the caller
1613        // verbatim rather than being auto-followed. Critically, for the
1614        // pin-only path this stops reqwest from silently following a cross-host
1615        // redirect and re-resolving the new host via normal DNS — which would
1616        // defeat the pin. Callers who want to follow redirects while staying
1617        // pinned/rebind-safe use `get_ssrf_safe` (or `follow_redirects`).
1618        let policy = reqwest::redirect::Policy::none();
1619        let resolve = self.pin_resolve()?;
1620        let client = build_oneshot_client(resolve, policy, timeout)?;
1621        send_one(
1622            &client,
1623            &self.method,
1624            &self.url,
1625            &self.extra_headers,
1626            self.body.as_ref(),
1627            &self.retry_policy,
1628        )
1629        .await
1630    }
1631
1632    /// Compute the `(host, addr)` resolve override for a pinned request, if any.
1633    fn pin_resolve(&self) -> Result<Option<(String, Vec<SocketAddr>)>, ClientError> {
1634        match self.pin_addr {
1635            // Single-address pin routed through the same set-based path as the
1636            // multi-address SSRF-safe pin (a one-element slice).
1637            Some(addr) => Ok(Some((host_of(&self.url)?, vec![addr]))),
1638            None => Ok(None),
1639        }
1640    }
1641
1642    /// Manual redirect-following loop with per-hop validation (Feature #1238).
1643    async fn follow_loop(
1644        self,
1645        max: usize,
1646        validator: RedirectValidator,
1647        timeout: Duration,
1648    ) -> Result<Response, ClientError> {
1649        let original =
1650            url::Url::parse(&self.url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1651        let mut current = self.url.clone();
1652        // Threaded across hops so cross-origin header stripping (Fix A) and
1653        // RFC method/body rewriting (Fix B) accumulate correctly.
1654        let mut method = self.method.clone();
1655        let mut headers = self.extra_headers.clone();
1656        let mut body = self.body.clone();
1657        for hop in 0.. {
1658            // Pin only applies to the first hop's original target.
1659            let resolve = if hop == 0 {
1660                match self.pin_addr {
1661                    Some(addr) => Some((host_of(&current)?, vec![addr])),
1662                    None => None,
1663                }
1664            } else {
1665                None
1666            };
1667            // On any post-origin hop, drop credential-bearing headers if the
1668            // current target is cross-origin (stays stripped once stripped).
1669            if hop > 0 {
1670                strip_sensitive_headers_if_cross_origin(&mut headers, &original, &current)?;
1671            }
1672            let client = build_oneshot_client(resolve, reqwest::redirect::Policy::none(), timeout)?;
1673            let resp = send_one(
1674                &client,
1675                &method,
1676                &current,
1677                &headers,
1678                body.as_ref(),
1679                &self.retry_policy,
1680            )
1681            .await?;
1682
1683            let Some(next) = redirect_target(&resp, &current)? else {
1684                return Ok(resp);
1685            };
1686            if hop >= max {
1687                return Err(ClientError::TooManyRedirects(max));
1688            }
1689            if !validator(&next) {
1690                return Err(ClientError::RedirectRejected(next));
1691            }
1692            // RFC 7231/7538 method+body rewriting before the next hop.
1693            rewrite_after_redirect(resp.status(), &mut method, &mut body, &mut headers);
1694            current = next;
1695        }
1696        unreachable!("redirect loop is bounded by `max` and always returns")
1697    }
1698
1699    /// Derive the SSRF-safe redirect plan `(follow, max)` from the builder's
1700    /// [`RedirectMode`], so a chained `no_redirect()` / `follow_redirects(..)`
1701    /// overrides the default hop cap on the SSRF-safe path:
1702    ///
1703    /// - [`RedirectMode::Default`] → `(true, SSRF_SAFE_MAX_REDIRECTS)`.
1704    /// - [`RedirectMode::None`] (`no_redirect()`) → `(false, 0)` — the initial
1705    ///   `3xx` is returned verbatim (the `max` is unused).
1706    /// - [`RedirectMode::Follow { max, .. }`] (`follow_redirects(max, ..)`) →
1707    ///   `(true, max)`. The caller's per-hop validator is pulled from
1708    ///   `self.redirect_mode` separately inside the send loop.
1709    const fn ssrf_redirect_plan(&self) -> (bool, usize) {
1710        match &self.redirect_mode {
1711            RedirectMode::Default => (true, SSRF_SAFE_MAX_REDIRECTS),
1712            RedirectMode::None => (false, 0),
1713            RedirectMode::Follow { max, .. } => (true, *max),
1714        }
1715    }
1716
1717    /// Composed SSRF-safe send path (Features #1238 + #1239). Resolves and
1718    /// validates every hop, pins the connection, and rejects scheme downgrades.
1719    ///
1720    /// The follow/hop-cap behaviour comes from [`ssrf_redirect_plan`](Self::ssrf_redirect_plan),
1721    /// so a chained `no_redirect()` / `follow_redirects(max, ..)` overrides the
1722    /// default cap while every per-hop safety step (resolve→validate→pin,
1723    /// https→http downgrade block, sensitive-header stripping, method/body
1724    /// rewrite) still applies.
1725    async fn send_ssrf_safe(self, timeout: Duration) -> Result<Response, ClientError> {
1726        let (follow, max) = self.ssrf_redirect_plan();
1727        let original =
1728            url::Url::parse(&self.url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1729        let mut current = self.url.clone();
1730        // Threaded across hops so cross-origin header stripping (Fix A) and
1731        // RFC method/body rewriting (Fix B) accumulate correctly.
1732        let mut method = self.method.clone();
1733        let mut headers = self.extra_headers.clone();
1734        let mut body = self.body.clone();
1735        for hop in 0.. {
1736            // Resolve host → ALL validated addresses (rejects if ANY resolved IP
1737            // is blocked), then pin the full set so reqwest cannot re-resolve but
1738            // can still fall back across the validated addresses in order.
1739            let addrs = resolve_and_validate(&current).await?;
1740            let host = host_of(&current)?;
1741            let client = build_oneshot_client(
1742                Some((host, addrs)),
1743                reqwest::redirect::Policy::none(),
1744                timeout,
1745            )?;
1746            // On any post-origin hop, drop credential-bearing headers if the
1747            // current target is cross-origin (stays stripped once stripped).
1748            if hop > 0 {
1749                strip_sensitive_headers_if_cross_origin(&mut headers, &original, &current)?;
1750            }
1751            let resp = send_one(
1752                &client,
1753                &method,
1754                &current,
1755                &headers,
1756                body.as_ref(),
1757                &self.retry_policy,
1758            )
1759            .await?;
1760
1761            // Honour a chained `no_redirect()`: return the response verbatim
1762            // BEFORE parsing the `Location` header. The initial URL was still
1763            // resolved / validated / pinned above. Parsing `Location` here (via
1764            // `redirect_target`) would let an untrusted server force an
1765            // `InvalidUrl` error out of a `no_redirect()` fetch by returning a
1766            // followable 3xx with a malformed `Location`, violating the
1767            // documented "return the initial 3xx verbatim" contract.
1768            if !follow {
1769                return Ok(resp);
1770            }
1771            let Some(next) = redirect_target(&resp, &current)? else {
1772                return Ok(resp);
1773            };
1774            if hop >= max {
1775                return Err(ClientError::TooManyRedirects(max));
1776            }
1777            // Defence-in-depth: reject an https→http downgrade on redirect.
1778            if scheme_is_https(&current)? && !scheme_is_https(&next)? {
1779                return Err(ClientError::RedirectRejected(format!(
1780                    "https→http scheme downgrade on redirect to {next}"
1781                )));
1782            }
1783            // Caller-supplied per-hop validator, present only in the
1784            // `follow_redirects` override. It runs in ADDITION to the built-in
1785            // resolve/validate/pin already applied at the top of the loop.
1786            if let RedirectMode::Follow { validator, .. } = &self.redirect_mode
1787                && !validator(&next)
1788            {
1789                return Err(ClientError::RedirectRejected(next));
1790            }
1791            // RFC 7231/7538 method+body rewriting before the next hop.
1792            rewrite_after_redirect(resp.status(), &mut method, &mut body, &mut headers);
1793            current = next;
1794            // The next loop iteration re-resolves + re-validates `current`
1795            // before connecting, so a redirect to a blocked address is rejected
1796            // there with `SsrfBlocked`.
1797        }
1798        unreachable!("redirect loop is bounded by the SSRF-safe redirect plan")
1799    }
1800}
1801
1802// ── Internal helpers ─────────────────────────────────────────────────────────
1803
1804const fn is_idempotent_method(method: &Method) -> bool {
1805    matches!(
1806        *method,
1807        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS | Method::TRACE
1808    )
1809}
1810
1811const fn is_retryable_status(status: u16) -> bool {
1812    matches!(status, 502..=504)
1813}
1814
1815// ── Custom send-path helpers (redirect / pin / SSRF-safe) ─────────────────────
1816
1817/// Build a one-shot `reqwest::Client` for the custom send path, with the given
1818/// redirect policy, per-request timeout, and optional DNS `resolve` override.
1819///
1820/// **Proxy bypass on pinned clients.** When a `resolve` override is present the
1821/// client is built with `.no_proxy()` so the request connects DIRECTLY to the
1822/// validated/pinned address. reqwest evaluates proxy interception (from
1823/// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`) BEFORE the connector where the
1824/// `resolve()` override applies, so a configured env proxy would otherwise send
1825/// the request to the proxy — which re-resolves the target host, reopening the
1826/// exact DNS-rebinding / SSRF window that pinning closes. Non-pinned callers
1827/// (`resolve == None`) keep reqwest's default proxy behaviour untouched.
1828fn build_oneshot_client(
1829    resolve: Option<(String, Vec<SocketAddr>)>,
1830    policy: reqwest::redirect::Policy,
1831    timeout: Duration,
1832) -> Result<reqwest::Client, ClientError> {
1833    let mut builder = reqwest::ClientBuilder::new()
1834        .timeout(timeout)
1835        .redirect(policy);
1836    if let Some((host, addrs)) = resolve
1837        && !addrs.is_empty()
1838    {
1839        // Pin to the FULL validated set: reqwest tries the addresses in order and
1840        // falls back on connection failure, so an unreachable first address no
1841        // longer dooms the request. Every pinned address was already validated,
1842        // so the TOCTOU/SSRF guarantee is preserved. A pinned request must never
1843        // route through a re-resolving proxy.
1844        builder = builder.no_proxy().resolve_to_addrs(&host, &addrs);
1845    }
1846    builder.build().map_err(ClientError::Request)
1847}
1848
1849/// Send a single request through `client` (no manual redirect following — the
1850/// client's redirect policy governs that) with the same transient-error and
1851/// 429/5xx retry behaviour as the shared path, and collect the [`Response`].
1852async fn send_one(
1853    client: &reqwest::Client,
1854    method: &Method,
1855    url: &str,
1856    extra_headers: &HeaderMap,
1857    body: Option<&Bytes>,
1858    retry_policy: &RetryPolicy,
1859) -> Result<Response, ClientError> {
1860    let start = Instant::now();
1861    let max_attempts = if is_idempotent_method(method) || !retry_policy.retry_idempotent_only {
1862        retry_policy.max_retries.saturating_add(1)
1863    } else {
1864        1
1865    };
1866
1867    for attempt in 0..max_attempts {
1868        if attempt > 0 {
1869            let exp = (attempt - 1).min(10);
1870            let delay = Duration::from_millis(100 * (1_u64 << exp));
1871            tokio::time::sleep(delay).await;
1872        }
1873
1874        let mut req = client.request(method.clone(), url);
1875        req = inject_trace_context(req);
1876        for (name, value) in extra_headers {
1877            req = req.header(name.clone(), value.clone());
1878        }
1879        if let Some(body) = body {
1880            req = req.body(body.clone());
1881        }
1882
1883        match req.send().await {
1884            Ok(resp) => {
1885                let status = resp.status();
1886                let headers = resp.headers().clone();
1887                let url_used = resp.url().clone();
1888
1889                if status.as_u16() == 429 && attempt + 1 < max_attempts {
1890                    let mut sleep_delay =
1891                        parse_retry_after(&headers).unwrap_or(Duration::from_secs(1));
1892                    sleep_delay = sleep_delay.min(retry_policy.max_retry_after);
1893                    if let Some(req_timeout) = retry_policy.request_timeout {
1894                        sleep_delay = sleep_delay.min(req_timeout);
1895                    }
1896                    tokio::time::sleep(sleep_delay).await;
1897                    continue;
1898                }
1899                if is_retryable_status(status.as_u16()) && attempt + 1 < max_attempts {
1900                    continue;
1901                }
1902
1903                let body = resp
1904                    .bytes()
1905                    .await
1906                    .map_err(|e| ClientError::Request(e.without_url()))?;
1907                log_request(
1908                    method.as_str(),
1909                    &url_used,
1910                    status.as_u16(),
1911                    start.elapsed(),
1912                    extra_headers,
1913                );
1914                return Ok(Response {
1915                    status,
1916                    headers,
1917                    body,
1918                    url: Some(url_used),
1919                });
1920            }
1921            Err(e) if (e.is_connect() || e.is_timeout()) && attempt + 1 < max_attempts => {}
1922            Err(e) => return Err(ClientError::Request(e.without_url())),
1923        }
1924    }
1925
1926    unreachable!("retry loop exited without returning a result — this is a bug")
1927}
1928
1929/// Extract the host portion of a URL as an owned `String`.
1930fn host_of(url: &str) -> Result<String, ClientError> {
1931    let parsed = url::Url::parse(url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1932    parsed
1933        .host_str()
1934        .map(str::to_owned)
1935        .ok_or_else(|| ClientError::InvalidUrl(format!("URL has no host: {url}")))
1936}
1937
1938/// `true` when the URL's host is an IP literal (IPv4 or IPv6) rather than a
1939/// domain name. Uses the `url` crate's parsed [`url::Host`] so bracketed IPv6
1940/// literals and decimal/octal/hex IPv4 encodings are classified correctly.
1941fn url_host_is_ip_literal(url: &str) -> Result<bool, ClientError> {
1942    let parsed = url::Url::parse(url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1943    match parsed.host() {
1944        Some(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => Ok(true),
1945        Some(url::Host::Domain(_)) => Ok(false),
1946        None => Err(ClientError::InvalidUrl(format!("URL has no host: {url}"))),
1947    }
1948}
1949
1950/// `true` when the URL's scheme is `https` (case-insensitive).
1951fn scheme_is_https(url: &str) -> Result<bool, ClientError> {
1952    let parsed = url::Url::parse(url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1953    Ok(parsed.scheme().eq_ignore_ascii_case("https"))
1954}
1955
1956/// If `resp` is a followable redirect (status `301`, `302`, `303`, `307`, or
1957/// `308`) carrying a `Location` header, resolve it to an absolute URL (joining
1958/// relative locations against `base`). Returns `Ok(None)` when the response is
1959/// not a followable redirect: any status outside that set (including non-3xx and
1960/// the non-followable 3xx `300`/`304`/`305`/`306`), or a followable status
1961/// missing its `Location` header.
1962fn redirect_target(resp: &Response, base: &str) -> Result<Option<String>, ClientError> {
1963    // Only the statuses reqwest itself follows are treated as redirects. A
1964    // response like `304 Not Modified` (or 300/305/306) can legitimately carry
1965    // a `Location` header without being a followable redirect, so matching the
1966    // entire 300–399 range via `is_redirection()` would wrongly issue an extra
1967    // request instead of returning the response to the caller.
1968    match resp.status() {
1969        reqwest::StatusCode::MOVED_PERMANENTLY
1970        | reqwest::StatusCode::FOUND
1971        | reqwest::StatusCode::SEE_OTHER
1972        | reqwest::StatusCode::TEMPORARY_REDIRECT
1973        | reqwest::StatusCode::PERMANENT_REDIRECT => {}
1974        _ => return Ok(None),
1975    }
1976    let Some(location) = resp
1977        .headers()
1978        .get(reqwest::header::LOCATION)
1979        .and_then(|v| v.to_str().ok())
1980    else {
1981        return Ok(None);
1982    };
1983    let base_url = url::Url::parse(base).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1984    let joined = base_url
1985        .join(location)
1986        .map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
1987    Ok(Some(joined.to_string()))
1988}
1989
1990/// Strip credential-bearing headers when a redirect hop crosses origins.
1991///
1992/// If `current`'s origin (scheme + host + port, per [`url::Url::origin`])
1993/// differs from the `original` request URL's origin, the `Authorization`,
1994/// `Cookie`, and `Proxy-Authorization` headers are removed from `headers` so
1995/// they are never forwarded to a cross-origin target (credential leak).
1996/// Because the caller threads a single mutable `headers` map across hops, once
1997/// these headers are stripped on any hop they stay stripped for the remainder
1998/// of the chain — the safe, conservative behaviour.
1999fn strip_sensitive_headers_if_cross_origin(
2000    headers: &mut HeaderMap,
2001    original: &url::Url,
2002    current: &str,
2003) -> Result<(), ClientError> {
2004    let current_url =
2005        url::Url::parse(current).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
2006    if current_url.origin() != original.origin() {
2007        headers.remove(reqwest::header::AUTHORIZATION);
2008        headers.remove(reqwest::header::COOKIE);
2009        headers.remove(reqwest::header::PROXY_AUTHORIZATION);
2010    }
2011    Ok(())
2012}
2013
2014/// Apply RFC 7231 §6.4 / RFC 7538 method-and-body rewriting after receiving a
2015/// redirect `status`, before issuing the next hop. Mutates `method` and `body`
2016/// in place.
2017///
2018/// - **303 See Other**: switch to `GET` (a `HEAD` stays `HEAD`) and drop the body.
2019/// - **301 Moved Permanently / 302 Found**: a `POST` becomes a bodyless `GET`;
2020///   every other method (and its body) is preserved — matching prevailing
2021///   browser behaviour.
2022/// - **307 Temporary Redirect / 308 Permanent Redirect**: preserve the method
2023///   **and** the body verbatim (the RFC-correct behaviour — the body must NOT
2024///   be dropped).
2025/// - Any other redirect status: leave method and body untouched.
2026///
2027/// Whenever the body is dropped (the POST→GET / 303→GET rewrites), the payload
2028/// (entity) headers threaded across hops are also removed from `headers` so the
2029/// bodyless follow-up hop does not carry a misleading `Content-Type` /
2030/// `Content-Length` / `Transfer-Encoding` / `Content-Encoding` /
2031/// `Content-Language` — matching reqwest's redirect layer. On 307/308 the body
2032/// is preserved, so those headers are left intact.
2033fn rewrite_after_redirect(
2034    status: reqwest::StatusCode,
2035    method: &mut Method,
2036    body: &mut Option<Bytes>,
2037    headers: &mut HeaderMap,
2038) {
2039    match status.as_u16() {
2040        303 => {
2041            if *method != Method::HEAD {
2042                *method = Method::GET;
2043            }
2044            *body = None;
2045            strip_payload_headers(headers);
2046        }
2047        301 | 302 if *method == Method::POST => {
2048            *method = Method::GET;
2049            *body = None;
2050            strip_payload_headers(headers);
2051        }
2052        _ => {}
2053    }
2054}
2055
2056/// Remove payload (entity) headers from the threaded per-hop header map. Called
2057/// when a redirect rewrite drops the request body so a bodyless GET does not
2058/// keep carrying the original payload's `Content-Type` etc.
2059fn strip_payload_headers(headers: &mut HeaderMap) {
2060    use reqwest::header::{
2061        CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING,
2062    };
2063    headers.remove(CONTENT_TYPE);
2064    headers.remove(CONTENT_LENGTH);
2065    headers.remove(TRANSFER_ENCODING);
2066    headers.remove(CONTENT_ENCODING);
2067    headers.remove(CONTENT_LANGUAGE);
2068}
2069
2070/// Validate a set of resolved socket addresses against the built-in SSRF
2071/// deny-list, failing closed.
2072///
2073/// Returns `Err(SsrfBlocked)` (naming the first blocked address) if **any**
2074/// address's IP is blocked ([`is_blocked_ip`]); otherwise returns the whole set
2075/// unchanged, preserving order. Factored out of [`resolve_and_validate`] as a
2076/// pure, synchronous helper so the validation policy is unit-testable without
2077/// real multi-record DNS.
2078fn validate_resolved_addrs(addrs: Vec<SocketAddr>) -> Result<Vec<SocketAddr>, ClientError> {
2079    for addr in &addrs {
2080        if is_blocked_ip(addr.ip()) {
2081            return Err(ClientError::SsrfBlocked(addr.ip().to_string()));
2082        }
2083    }
2084    Ok(addrs)
2085}
2086
2087/// Resolve `url`'s host to **all** validated [`SocketAddr`]s, rejecting with
2088/// [`ClientError::SsrfBlocked`] if the host is (or resolves to) any blocked IP.
2089///
2090/// IP-literal hosts (including decimal/octal/hex encodings, which the `url`
2091/// crate normalises to an `Ipv4Addr` at parse time) are validated directly with
2092/// no DNS lookup. Domain hosts are resolved **once** via `tokio::net::lookup_host`
2093/// (keeping the TOCTOU window closed) and rejected if **any** resolved address
2094/// is blocked. Since the whole DNS response is rejected when any IP is blocked,
2095/// it is safe to return the full validated set (order preserved) so a caller can
2096/// pin all of them and let reqwest try them in order — an unreachable first
2097/// address no longer dooms the request.
2098async fn resolve_and_validate(url: &str) -> Result<Vec<SocketAddr>, ClientError> {
2099    let parsed = url::Url::parse(url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
2100    // Explicit scheme allowlist (defence-in-depth): only http/https may be
2101    // resolved and connected on the safe path. Reject ftp://, gopher://,
2102    // file://, etc. here — before any DNS lookup or connection — rather than
2103    // relying on reqwest to reject them after the fact.
2104    let scheme = parsed.scheme();
2105    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
2106        return Err(ClientError::InvalidUrl(format!(
2107            "unsupported URL scheme `{scheme}` (only http/https are allowed): {url}"
2108        )));
2109    }
2110    let port = parsed.port_or_known_default().ok_or_else(|| {
2111        ClientError::InvalidUrl(format!("URL has no port and unknown scheme: {url}"))
2112    })?;
2113    let host = parsed
2114        .host()
2115        .ok_or_else(|| ClientError::InvalidUrl(format!("URL has no host: {url}")))?;
2116
2117    match host {
2118        url::Host::Ipv4(v4) => validate_resolved_addrs(vec![SocketAddr::new(IpAddr::V4(v4), port)]),
2119        url::Host::Ipv6(v6) => validate_resolved_addrs(vec![SocketAddr::new(IpAddr::V6(v6), port)]),
2120        url::Host::Domain(name) => {
2121            let addrs: Vec<SocketAddr> = tokio::net::lookup_host((name, port))
2122                .await
2123                .map_err(|e| ClientError::InvalidUrl(format!("DNS lookup failed for {name}: {e}")))?
2124                .collect();
2125            if addrs.is_empty() {
2126                return Err(ClientError::InvalidUrl(format!(
2127                    "DNS lookup for {name} returned no addresses"
2128                )));
2129            }
2130            // Reject if ANY resolved address is blocked (fail closed); otherwise
2131            // return the whole validated set (order preserved from the lookup).
2132            validate_resolved_addrs(addrs)
2133        }
2134    }
2135}
2136
2137fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
2138    let value = headers.get("retry-after")?.to_str().ok()?;
2139    // Integer seconds (most common form).
2140    if let Ok(secs) = value.parse::<u64>() {
2141        return Some(Duration::from_secs(secs));
2142    }
2143    // HTTP-date format per RFC 9110 (e.g. "Tue, 01 Jan 2030 00:00:00 GMT").
2144    let dt = chrono::DateTime::parse_from_rfc2822(value).ok()?;
2145    let now = chrono::Utc::now();
2146    let future = dt.with_timezone(&chrono::Utc);
2147    let secs = u64::try_from((future - now).num_seconds().max(0)).unwrap_or(0);
2148    Some(Duration::from_secs(secs))
2149}
2150
2151const REDACTED_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie"];
2152
2153fn is_sensitive_header(name: &str) -> bool {
2154    REDACTED_HEADERS
2155        .iter()
2156        .any(|h| h.eq_ignore_ascii_case(name))
2157}
2158
2159fn log_request(
2160    method: &str,
2161    url: &reqwest::Url,
2162    status: u16,
2163    elapsed: Duration,
2164    headers: &HeaderMap,
2165) {
2166    let host = url.host_str().unwrap_or("unknown");
2167    let path = url.path();
2168
2169    // Collect non-sensitive header names for the span (values are omitted).
2170    let sent_headers: Vec<&str> = headers
2171        .keys()
2172        .map(HeaderName::as_str)
2173        .filter(|k| !is_sensitive_header(k))
2174        .collect();
2175
2176    tracing::info!(
2177        http.method = method,
2178        http.host = host,
2179        http.path = path,
2180        http.status = status,
2181        http.elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
2182        http.sent_headers = ?sent_headers,
2183        "outbound request"
2184    );
2185}
2186
2187/// Inject the active span's W3C `traceparent` / `tracestate` headers into the
2188/// request builder.  No-ops when the `telemetry-otlp` feature is disabled or
2189/// when there is no active span with a valid context.
2190#[allow(clippy::missing_const_for_fn)]
2191fn inject_trace_context(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
2192    #[cfg(not(feature = "telemetry-otlp"))]
2193    {
2194        builder
2195    }
2196    #[cfg(feature = "telemetry-otlp")]
2197    {
2198        use std::collections::HashMap;
2199        use tracing_opentelemetry::OpenTelemetrySpanExt as _;
2200        let cx = tracing::Span::current().context();
2201        let mut map = HashMap::<String, String>::new();
2202        opentelemetry::global::get_text_map_propagator(|propagator| {
2203            propagator.inject_context(&cx, &mut TraceHeaderInjector(&mut map));
2204        });
2205        let mut builder = builder;
2206        for (k, v) in map {
2207            if let Ok(value) = HeaderValue::from_str(&v) {
2208                builder = builder.header(k, value);
2209            }
2210        }
2211        builder
2212    }
2213}
2214
2215#[cfg(feature = "telemetry-otlp")]
2216struct TraceHeaderInjector<'a>(&'a mut std::collections::HashMap<String, String>);
2217
2218#[cfg(feature = "telemetry-otlp")]
2219impl opentelemetry::propagation::Injector for TraceHeaderInjector<'_> {
2220    fn set(&mut self, key: &str, value: String) {
2221        self.0.insert(key.to_owned(), value);
2222    }
2223}
2224
2225// ── Tests ────────────────────────────────────────────────────────────────────
2226
2227#[cfg(test)]
2228mod tests {
2229    use super::*;
2230    use crate::config::HttpClientConfig;
2231
2232    // RED-PHASE TEST 1: Client can be constructed with defaults.
2233    #[test]
2234    fn client_constructs_with_defaults() {
2235        let client = Client::new();
2236        assert!(client.alias.is_none());
2237        assert!(client.base_url.is_none());
2238        assert_eq!(client.retry_policy.max_retries, 3);
2239    }
2240
2241    // RED-PHASE TEST 2: Fluent RequestBuilder API compiles.
2242    #[test]
2243    fn request_builder_fluent_api_compiles() {
2244        let client = Client::new();
2245        let _builder = client
2246            .post("https://example.com/api")
2247            .header("x-api-key", "secret")
2248            .json(&serde_json::json!({"key": "value"}))
2249            .retries(2);
2250    }
2251
2252    // RED-PHASE TEST 3: Response accessors work.
2253    #[test]
2254    fn response_accessors_work() {
2255        let payload = serde_json::json!({"id": 42, "name": "Alice"});
2256        let body = serde_json::to_vec(&payload).unwrap();
2257        let resp = Response {
2258            status: reqwest::StatusCode::OK,
2259            headers: HeaderMap::new(),
2260            body: Bytes::from(body),
2261            url: None,
2262        };
2263        assert_eq!(resp.status().as_u16(), 200);
2264        assert!(resp.is_success());
2265    }
2266
2267    // RED-PHASE TEST 4: Response::json() deserialises correctly.
2268    #[test]
2269    fn response_json_deserialises() {
2270        #[derive(serde::Deserialize, PartialEq, Debug)]
2271        struct User {
2272            id: i32,
2273            name: String,
2274        }
2275        let payload = serde_json::json!({"id": 1, "name": "Bob"});
2276        let resp = Response {
2277            status: reqwest::StatusCode::OK,
2278            headers: HeaderMap::new(),
2279            body: Bytes::from(serde_json::to_vec(&payload).unwrap()),
2280            url: None,
2281        };
2282        let user: User = resp.json().unwrap();
2283        assert_eq!(user.id, 1);
2284        assert_eq!(user.name, "Bob");
2285    }
2286
2287    // RED-PHASE TEST 5: Response::text() returns UTF-8 string.
2288    #[test]
2289    fn response_text_returns_string() {
2290        let resp = Response {
2291            status: reqwest::StatusCode::OK,
2292            headers: HeaderMap::new(),
2293            body: Bytes::from_static(b"hello world"),
2294            url: None,
2295        };
2296        assert_eq!(resp.text(), "hello world");
2297    }
2298
2299    // RED-PHASE TEST 6: Response::bytes() returns raw bytes.
2300    #[test]
2301    fn response_bytes_returns_raw() {
2302        let resp = Response {
2303            status: reqwest::StatusCode::CREATED,
2304            headers: HeaderMap::new(),
2305            body: Bytes::from_static(b"\x00\x01\x02"),
2306            url: None,
2307        };
2308        assert_eq!(resp.bytes(), Bytes::from_static(b"\x00\x01\x02"));
2309    }
2310
2311    // RED-PHASE TEST 7: HttpClientConfig deserialises from [http.client] TOML.
2312    #[test]
2313    fn config_deserialises_from_toml() {
2314        // Simulate the [http.client] section as it appears in autumn.toml.
2315        let toml = r#"
2316            [client]
2317            timeout_secs = 60
2318            max_retries = 5
2319            [client.base_urls]
2320            stripe = "https://api.stripe.com"
2321            sendgrid = "https://api.sendgrid.com"
2322        "#;
2323        let http_cfg: crate::config::HttpConfig = toml::from_str(toml).unwrap();
2324        let config = &http_cfg.client;
2325        assert_eq!(config.timeout_secs, 60);
2326        assert_eq!(config.max_retries, 5);
2327        assert_eq!(
2328            config.base_urls.get("stripe").map(String::as_str),
2329            Some("https://api.stripe.com")
2330        );
2331        assert_eq!(
2332            config.base_urls.get("sendgrid").map(String::as_str),
2333            Some("https://api.sendgrid.com")
2334        );
2335    }
2336
2337    // RED-PHASE TEST 8: HttpClientConfig has correct defaults.
2338    #[test]
2339    fn config_has_correct_defaults() {
2340        let config = HttpClientConfig::default();
2341        assert_eq!(config.timeout_secs, 30);
2342        assert_eq!(config.max_retries, 3);
2343        assert!(config.base_urls.is_empty());
2344    }
2345
2346    // RED-PHASE TEST 9: is_idempotent_method returns correct values.
2347    #[test]
2348    fn idempotent_method_classification() {
2349        assert!(is_idempotent_method(&Method::GET));
2350        assert!(is_idempotent_method(&Method::HEAD));
2351        assert!(is_idempotent_method(&Method::PUT));
2352        assert!(is_idempotent_method(&Method::DELETE));
2353        assert!(is_idempotent_method(&Method::OPTIONS));
2354        assert!(is_idempotent_method(&Method::TRACE));
2355        assert!(!is_idempotent_method(&Method::POST));
2356        assert!(!is_idempotent_method(&Method::PATCH));
2357    }
2358
2359    // RED-PHASE TEST 10: is_retryable_status returns correct values.
2360    #[test]
2361    fn retryable_status_classification() {
2362        assert!(is_retryable_status(502));
2363        assert!(is_retryable_status(503));
2364        assert!(is_retryable_status(504));
2365        assert!(!is_retryable_status(200));
2366        assert!(!is_retryable_status(400));
2367        assert!(!is_retryable_status(404));
2368        assert!(!is_retryable_status(500));
2369        assert!(!is_retryable_status(429));
2370    }
2371
2372    // RED-PHASE TEST 11: parse_retry_after parses seconds correctly.
2373    #[test]
2374    fn retry_after_header_parsing() {
2375        let mut headers = HeaderMap::new();
2376        headers.insert(
2377            reqwest::header::HeaderName::from_static("retry-after"),
2378            HeaderValue::from_static("5"),
2379        );
2380        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(5)));
2381
2382        let empty = HeaderMap::new();
2383        assert_eq!(parse_retry_after(&empty), None);
2384    }
2385
2386    // RED-PHASE TEST 12: Sensitive header detection.
2387    #[test]
2388    fn sensitive_header_detection() {
2389        assert!(is_sensitive_header("authorization"));
2390        assert!(is_sensitive_header("Authorization"));
2391        assert!(is_sensitive_header("AUTHORIZATION"));
2392        assert!(is_sensitive_header("cookie"));
2393        assert!(is_sensitive_header("set-cookie"));
2394        assert!(!is_sensitive_header("content-type"));
2395        assert!(!is_sensitive_header("x-api-key"));
2396    }
2397
2398    // RED-PHASE TEST 13: MockRegistry captures and matches calls.
2399    #[tokio::test]
2400    async fn mock_registry_captures_calls() {
2401        let registry = Arc::new(MockRegistry::new());
2402        let call_count = Arc::new(AtomicUsize::new(0));
2403
2404        registry.register(MockEntry {
2405            method: Some(Method::POST),
2406            path: "/charges".to_owned(),
2407            alias: Some("stripe".to_owned()),
2408            status: 200,
2409            body: Some(serde_json::json!({"id": "ch_123"})),
2410            call_count: call_count.clone(),
2411        });
2412
2413        let client = Client::new().with_mock(registry).named("stripe");
2414
2415        let resp = client
2416            .post("https://api.stripe.com/charges")
2417            .json(&serde_json::json!({"amount": 1000}))
2418            .send()
2419            .await
2420            .unwrap();
2421
2422        assert_eq!(resp.status().as_u16(), 200);
2423        let body: serde_json::Value = resp.json().unwrap();
2424        assert_eq!(body["id"], "ch_123");
2425        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2426    }
2427
2428    // RED-PHASE TEST 14: MockHandle::expect_called passes when count matches.
2429    #[tokio::test]
2430    async fn mock_handle_expect_called_passes() {
2431        let registry = Arc::new(MockRegistry::new());
2432        let call_count = Arc::new(AtomicUsize::new(0));
2433
2434        registry.register(MockEntry {
2435            method: Some(Method::GET),
2436            path: "/users/1".to_owned(),
2437            alias: None,
2438            status: 200,
2439            body: Some(serde_json::json!({"name": "Alice"})),
2440            call_count: call_count.clone(),
2441        });
2442
2443        let handle = MockHandle {
2444            alias: "test".to_owned(),
2445            method: "GET".to_owned(),
2446            path: "/users/1".to_owned(),
2447            call_count: call_count.clone(),
2448        };
2449
2450        let client = Client::new().with_mock(registry);
2451        client
2452            .get("https://api.example.com/users/1")
2453            .send()
2454            .await
2455            .unwrap();
2456
2457        handle.expect_called(1);
2458        assert_eq!(handle.call_count(), 1);
2459    }
2460
2461    // RED-PHASE TEST 15: MockRegistry matches by URL path suffix.
2462    #[tokio::test]
2463    async fn mock_matches_by_path_suffix() {
2464        let registry = Arc::new(MockRegistry::new());
2465        let call_count = Arc::new(AtomicUsize::new(0));
2466
2467        registry.register(MockEntry {
2468            method: Some(Method::POST),
2469            path: "/v1/charges".to_owned(),
2470            alias: None,
2471            status: 201,
2472            body: Some(serde_json::json!({"created": true})),
2473            call_count: call_count.clone(),
2474        });
2475
2476        let client = Client::new().with_mock(registry);
2477        let resp = client
2478            .post("https://api.stripe.com/v1/charges")
2479            .send()
2480            .await
2481            .unwrap();
2482
2483        assert_eq!(resp.status().as_u16(), 201);
2484        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2485    }
2486
2487    // RED-PHASE TEST 16: NoMock error when mock registry has no match.
2488    #[tokio::test]
2489    async fn no_mock_error_when_unmatched() {
2490        let registry = Arc::new(MockRegistry::new());
2491        let client = Client::new().with_mock(registry);
2492        let result = client.post("https://api.example.com/unknown").send().await;
2493        assert!(matches!(result, Err(ClientError::NoMock(_, _))));
2494    }
2495
2496    // RED-PHASE TEST 17: MockSetupBuilder registers and returns MockHandle.
2497    #[tokio::test]
2498    async fn mock_setup_builder_registers_entry() {
2499        let registry = Arc::new(MockRegistry::new());
2500        let builder = MockSetupBuilder {
2501            registry: registry.clone(),
2502            alias: "myservice".to_owned(),
2503            method: None,
2504            path: None,
2505        };
2506
2507        let handle = builder
2508            .post("/api/resource")
2509            .respond_with(201, serde_json::json!({"ok": true}));
2510
2511        let client = Client::new().with_mock(registry).named("myservice");
2512        client
2513            .post("https://myservice.example.com/api/resource")
2514            .send()
2515            .await
2516            .unwrap();
2517
2518        handle.expect_called(1);
2519    }
2520
2521    // RED-PHASE TEST 18: Client::from_config respects timeout and retries.
2522    #[test]
2523    fn client_from_config() {
2524        let config = HttpClientConfig {
2525            timeout_secs: 10,
2526            max_retries: 1,
2527            max_retry_after_secs: 10,
2528            base_urls: std::collections::HashMap::new(),
2529        };
2530        let client = Client::from_config(&config);
2531        assert_eq!(client.retry_policy.max_retries, 1);
2532    }
2533
2534    // RED-PHASE TEST 19: Client.named() preserves mock registry.
2535    #[test]
2536    fn named_client_preserves_mock_registry() {
2537        let registry = Arc::new(MockRegistry::new());
2538        let client = Client::new().with_mock(registry);
2539        let named = client.named("stripe");
2540        assert!(named.mock.is_some());
2541        assert_eq!(named.alias.as_deref(), Some("stripe"));
2542    }
2543
2544    // RED-PHASE TEST 20: base_url is prepended to relative paths.
2545    #[test]
2546    fn base_url_prepended_to_relative_path() {
2547        let client = Client::new();
2548        let client = client.with_base_url("https://api.stripe.com");
2549        let builder = client.post("/v1/charges");
2550        assert_eq!(builder.url, "https://api.stripe.com/v1/charges");
2551    }
2552
2553    // RED-PHASE TEST 21: Absolute URLs bypass base_url.
2554    #[test]
2555    fn absolute_url_bypasses_base_url() {
2556        let client = Client::new().with_base_url("https://ignored.example.com");
2557        let builder = client.get("https://actual.example.com/path");
2558        assert_eq!(builder.url, "https://actual.example.com/path");
2559    }
2560
2561    // RED-PHASE TEST 22: RetryPolicy can be overridden per-request.
2562    #[test]
2563    fn retry_override_per_request() {
2564        let client = Client::new(); // default: 3 retries
2565        let builder = client.get("https://example.com").retries(0);
2566        assert_eq!(builder.retry_policy.max_retries, 0);
2567
2568        let no_retry = client.get("https://example.com").no_retry();
2569        assert_eq!(no_retry.retry_policy.max_retries, 0);
2570    }
2571
2572    // RED-PHASE TEST 23: Client extracts from AppState.
2573    #[tokio::test]
2574    async fn client_extracts_from_state() {
2575        use axum::extract::FromRequestParts;
2576        let state = crate::AppState::for_test();
2577        let mut parts = axum::http::Request::new(axum::body::Body::empty())
2578            .into_parts()
2579            .0;
2580        let client = Client::from_request_parts(&mut parts, &state)
2581            .await
2582            .unwrap();
2583        // Default client: no mock, no alias
2584        assert!(client.mock.is_none());
2585        assert!(client.alias.is_none());
2586    }
2587
2588    // RED-PHASE TEST 24: MockRegistryExt round-trips through AppState extensions.
2589    #[test]
2590    fn mock_registry_ext_round_trips_through_state() {
2591        let registry = Arc::new(MockRegistry::new());
2592        let ext = HttpMockRegistryExt(registry);
2593        let state = crate::AppState::for_test();
2594        state.insert_extension(ext);
2595        let retrieved = state.extension::<HttpMockRegistryExt>();
2596        assert!(retrieved.is_some());
2597    }
2598
2599    // TEST 25: named() resolves base URL from base_urls map in config.
2600    #[test]
2601    fn named_client_resolves_base_url_from_config() {
2602        let mut base_urls = std::collections::HashMap::new();
2603        base_urls.insert("stripe".to_owned(), "https://api.stripe.com".to_owned());
2604        let config = HttpClientConfig {
2605            timeout_secs: 30,
2606            max_retries: 3,
2607            max_retry_after_secs: 10,
2608            base_urls,
2609        };
2610        let client = Client::from_config(&config);
2611        let stripe = client.named("stripe");
2612        assert_eq!(stripe.base_url.as_deref(), Some("https://api.stripe.com"));
2613        assert_eq!(stripe.alias.as_deref(), Some("stripe"));
2614
2615        // Unknown alias falls back to client-level base_url (None in this case).
2616        let other = client.named("sendgrid");
2617        assert!(other.base_url.is_none());
2618    }
2619
2620    // TEST 26: from_request_parts uses AutumnConfig.http when no HttpConfig extension.
2621    #[tokio::test]
2622    async fn client_extracts_from_autumn_config_in_state() {
2623        use axum::extract::FromRequestParts;
2624        let mut cfg = crate::config::AutumnConfig::default();
2625        cfg.http.client.max_retries = 7;
2626        let state = crate::AppState::for_test();
2627        state.insert_extension(cfg);
2628
2629        let mut parts = axum::http::Request::new(axum::body::Body::empty())
2630            .into_parts()
2631            .0;
2632        let client = Client::from_request_parts(&mut parts, &state)
2633            .await
2634            .unwrap();
2635        assert_eq!(client.retry_policy.max_retries, 7);
2636    }
2637
2638    // TEST 27: respond_with_status produces a truly empty body (not JSON null).
2639    #[tokio::test]
2640    async fn respond_with_status_produces_empty_body() {
2641        let registry = Arc::new(MockRegistry::new());
2642        let builder = MockSetupBuilder {
2643            registry: registry.clone(),
2644            alias: "svc".to_owned(),
2645            method: None,
2646            path: None,
2647        };
2648        let _handle = builder.delete("/items/1").respond_with_status(204);
2649
2650        let client = Client::new().with_mock(registry).named("svc");
2651        let resp = client
2652            .delete("https://svc.example.com/items/1")
2653            .send()
2654            .await
2655            .unwrap();
2656
2657        assert_eq!(resp.status().as_u16(), 204);
2658        assert_eq!(
2659            resp.bytes(),
2660            bytes::Bytes::new(),
2661            "body must be empty, not \"null\""
2662        );
2663    }
2664
2665    // TEST 28: parse_retry_after handles HTTP-date format.
2666    #[test]
2667    fn retry_after_http_date_parsing() {
2668        let mut headers = HeaderMap::new();
2669        // A date far in the future to ensure the computed seconds > 0.
2670        headers.insert(
2671            reqwest::header::HeaderName::from_static("retry-after"),
2672            HeaderValue::from_static("Tue, 01 Jan 2030 00:00:00 GMT"),
2673        );
2674        let duration = parse_retry_after(&headers);
2675        assert!(duration.is_some(), "should parse HTTP-date Retry-After");
2676        assert!(
2677            duration.unwrap().as_secs() > 0,
2678            "future date should yield positive delay"
2679        );
2680    }
2681
2682    // TEST 29: non-idempotent POST with retries disabled makes only one attempt.
2683    #[tokio::test]
2684    async fn non_idempotent_post_no_retry() {
2685        let registry = Arc::new(MockRegistry::new());
2686        let call_count = Arc::new(AtomicUsize::new(0));
2687        registry.register(MockEntry {
2688            method: Some(Method::POST),
2689            path: "/endpoint".to_owned(),
2690            alias: None,
2691            status: 503,
2692            body: None,
2693            call_count: call_count.clone(),
2694        });
2695
2696        // With retry_idempotent_only=true (default), POST should NOT retry.
2697        let client = Client::new().with_mock(registry);
2698        let resp = client
2699            .post("https://example.com/endpoint")
2700            .send()
2701            .await
2702            .unwrap();
2703
2704        assert_eq!(resp.status().as_u16(), 503);
2705        // Mock was called exactly once — no retry for non-idempotent method.
2706        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2707    }
2708
2709    // TEST 30: find_match strips query string from relative URLs before comparing.
2710    #[tokio::test]
2711    async fn mock_strips_query_from_url_before_matching() {
2712        let registry = Arc::new(MockRegistry::new());
2713        let call_count = Arc::new(AtomicUsize::new(0));
2714        registry.register(MockEntry {
2715            method: Some(Method::GET),
2716            path: "/v1/charges".to_owned(),
2717            alias: None,
2718            status: 200,
2719            body: Some(serde_json::json!({"ok": true})),
2720            call_count: call_count.clone(),
2721        });
2722
2723        // The URL has a query string; the mock is registered without one.
2724        let client = Client::new().with_mock(registry);
2725        let resp = client
2726            .get("https://api.stripe.com/v1/charges?expand[]=balance_transaction")
2727            .send()
2728            .await
2729            .unwrap();
2730
2731        assert_eq!(resp.status().as_u16(), 200);
2732        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2733    }
2734
2735    // TEST 31: suffix match works when mock path starts with '/' and URL has a prefix.
2736    #[tokio::test]
2737    async fn mock_suffix_match_with_leading_slash_path() {
2738        let registry = Arc::new(MockRegistry::new());
2739        let call_count = Arc::new(AtomicUsize::new(0));
2740        // Register only the leaf segment (with leading slash).
2741        registry.register(MockEntry {
2742            method: Some(Method::POST),
2743            path: "/charges".to_owned(),
2744            alias: None,
2745            status: 201,
2746            body: Some(serde_json::json!({"matched": true})),
2747            call_count: call_count.clone(),
2748        });
2749
2750        let client = Client::new().with_mock(registry);
2751        // Full URL path is /v1/charges; mock path is /charges.
2752        let resp = client
2753            .post("https://api.stripe.com/v1/charges")
2754            .send()
2755            .await
2756            .unwrap();
2757
2758        assert_eq!(resp.status().as_u16(), 201);
2759        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2760    }
2761
2762    // TEST 32: retries() clears retry_idempotent_only so POST actually retries.
2763    #[test]
2764    fn retries_clears_idempotent_only_flag() {
2765        let client = Client::new();
2766        let builder = client.post("https://example.com").retries(2);
2767        assert_eq!(builder.retry_policy.max_retries, 2);
2768        assert!(
2769            !builder.retry_policy.retry_idempotent_only,
2770            "explicit retries() call must allow non-idempotent methods to retry"
2771        );
2772    }
2773
2774    // TEST 33: log_request covers the sensitive-header redaction path.
2775    #[test]
2776    fn log_request_completes_with_sensitive_headers() {
2777        let url = reqwest::Url::parse("https://api.example.com/v1/resource?q=1").unwrap();
2778        let mut headers = HeaderMap::new();
2779        headers.insert(
2780            HeaderName::from_static("content-type"),
2781            HeaderValue::from_static("application/json"),
2782        );
2783        headers.insert(
2784            HeaderName::from_static("authorization"),
2785            HeaderValue::from_static("Bearer sk_test_xxx"),
2786        );
2787        // Should complete without panicking; authorization is redacted from span.
2788        log_request("POST", &url, 201, Duration::from_millis(12), &headers);
2789    }
2790
2791    // TEST 34: inject_trace_context passthrough (without telemetry-otlp feature).
2792    #[test]
2793    fn inject_trace_context_passthrough_without_telemetry() {
2794        let inner = reqwest::Client::new();
2795        let builder = inner.get("https://example.com");
2796        // Without telemetry-otlp the function is a no-op; verify it doesn't panic.
2797        let _b = inject_trace_context(builder);
2798    }
2799
2800    // TEST 35: Real GET request exercises inject_trace_context, log_request, and
2801    // the success branch of the retry loop.
2802    #[tokio::test]
2803    #[allow(clippy::await_holding_lock)]
2804    async fn real_get_request_covers_network_path() {
2805        use axum::{Router, routing::get};
2806
2807        let _lock = crate::circuit_breaker::TEST_LOCK
2808            .lock()
2809            .unwrap_or_else(std::sync::PoisonError::into_inner);
2810        crate::circuit_breaker::global_registry().clear();
2811
2812        let app = Router::new().route("/ping", get(|| async { "pong" }));
2813        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2814        let addr = listener.local_addr().unwrap();
2815        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2816
2817        let client = Client::new();
2818        let resp = client
2819            .get(format!("http://127.0.0.1:{}/ping", addr.port()))
2820            .header("x-request-id", "test-35")
2821            .send()
2822            .await
2823            .unwrap();
2824
2825        assert_eq!(resp.status().as_u16(), 200);
2826        assert!(resp.url().is_some());
2827        assert_eq!(resp.text(), "pong");
2828
2829        crate::circuit_breaker::global_registry().clear();
2830    }
2831
2832    // TEST 36: Real POST with JSON body covers the body-sending code path.
2833    #[tokio::test]
2834    #[allow(clippy::await_holding_lock)]
2835    async fn real_post_with_json_body_covers_body_path() {
2836        use axum::{Json, Router, routing::post};
2837        use serde_json::Value;
2838
2839        let _lock = crate::circuit_breaker::TEST_LOCK
2840            .lock()
2841            .unwrap_or_else(std::sync::PoisonError::into_inner);
2842        crate::circuit_breaker::global_registry().clear();
2843
2844        let app = Router::new().route(
2845            "/echo",
2846            post(|Json(body): Json<Value>| async move { Json(body) }),
2847        );
2848        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2849        let addr = listener.local_addr().unwrap();
2850        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2851
2852        let client = Client::new();
2853        let resp = client
2854            .post(format!("http://127.0.0.1:{}/echo", addr.port()))
2855            .json(&serde_json::json!({"hello": "world"}))
2856            .send()
2857            .await
2858            .unwrap();
2859
2860        assert_eq!(resp.status().as_u16(), 200);
2861        let body: Value = resp.json().unwrap();
2862        assert_eq!(body["hello"], "world");
2863
2864        crate::circuit_breaker::global_registry().clear();
2865    }
2866
2867    // TEST 37: GET with one 503 then 200 covers the retry-sleep and 5xx-retry paths.
2868    #[tokio::test]
2869    #[allow(clippy::await_holding_lock)]
2870    async fn real_get_retries_on_503_then_succeeds() {
2871        use axum::{Router, routing::get};
2872        use std::sync::Arc;
2873        use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
2874
2875        let _lock = crate::circuit_breaker::TEST_LOCK
2876            .lock()
2877            .unwrap_or_else(std::sync::PoisonError::into_inner);
2878        crate::circuit_breaker::global_registry().clear();
2879
2880        let hit = Arc::new(AtomicU32::new(0));
2881        let hit2 = hit.clone();
2882        let app = Router::new().route(
2883            "/flaky",
2884            get(move || {
2885                let c = hit2.clone();
2886                async move {
2887                    if c.fetch_add(1, SeqOrdering::SeqCst) == 0 {
2888                        axum::http::StatusCode::SERVICE_UNAVAILABLE
2889                    } else {
2890                        axum::http::StatusCode::OK
2891                    }
2892                }
2893            }),
2894        );
2895        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2896        let addr = listener.local_addr().unwrap();
2897        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2898
2899        // retries(1): 2 total attempts, 100 ms sleep between them.
2900        let resp = Client::new()
2901            .get(format!("http://127.0.0.1:{}/flaky", addr.port()))
2902            .retries(1)
2903            .send()
2904            .await
2905            .unwrap();
2906
2907        assert_eq!(resp.status().as_u16(), 200);
2908        assert_eq!(hit.load(SeqOrdering::SeqCst), 2);
2909
2910        crate::circuit_breaker::global_registry().clear();
2911    }
2912
2913    // TEST 38: text_body sets a plain-text body.
2914    #[test]
2915    fn text_body_sets_body() {
2916        let client = Client::new();
2917        let builder = client.post("https://example.com").text_body("hello");
2918        assert_eq!(builder.body, Some(bytes::Bytes::from_static(b"hello")));
2919    }
2920
2921    // TEST 39: ClientError::NoMock displays correctly.
2922    #[test]
2923    fn client_error_display() {
2924        let err = ClientError::NoMock("GET".to_owned(), "/path".to_owned());
2925        assert!(err.to_string().contains("GET"));
2926        assert!(err.to_string().contains("/path"));
2927    }
2928
2929    // TEST 40: Outbound circuit breaker integration trips and fails fast.
2930    #[tokio::test]
2931    #[allow(clippy::await_holding_lock)]
2932    async fn test_http_client_circuit_breaker_integration() {
2933        use axum::{Router, routing::get};
2934        use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
2935
2936        let _lock = crate::circuit_breaker::TEST_LOCK
2937            .lock()
2938            .unwrap_or_else(std::sync::PoisonError::into_inner);
2939        crate::circuit_breaker::global_registry().clear();
2940
2941        let hit = Arc::new(AtomicU32::new(0));
2942        let hit2 = hit.clone();
2943        let app = Router::new().route(
2944            "/flaky",
2945            get(move || {
2946                let c = hit2.clone();
2947                async move {
2948                    c.fetch_add(1, SeqOrdering::SeqCst);
2949                    axum::http::StatusCode::INTERNAL_SERVER_ERROR
2950                }
2951            }),
2952        );
2953        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2954        let addr = listener.local_addr().unwrap();
2955        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2956
2957        // Build a resilience config with custom thresholds
2958        let mut rc = crate::config::ResilienceConfig::default();
2959        rc.circuit_breaker.defaults.failure_ratio_threshold = Some(0.5);
2960        rc.circuit_breaker.defaults.minimum_sample_count = Some(3);
2961        rc.circuit_breaker.defaults.open_duration_secs = Some(10);
2962
2963        let client = Client::new();
2964        // Attach the resilience config
2965        let client = Client {
2966            resilience_config: Some(Arc::new(rc)),
2967            ..client
2968        };
2969
2970        let url = format!("http://127.0.0.1:{}/flaky", addr.port());
2971
2972        // Send 3 requests (all fail with 500)
2973        for _ in 0..3 {
2974            let res = client.get(&url).send().await;
2975            let res = res.unwrap();
2976            assert_eq!(res.status().as_u16(), 500);
2977        }
2978
2979        // Now the breaker for 127.0.0.1 should be OPEN, and next request should fail fast
2980        let res = client.get(&url).send().await;
2981        assert!(matches!(res, Err(ClientError::CircuitBreakerOpen)));
2982
2983        // Assert that the server was only hit 3 times
2984        assert_eq!(hit.load(SeqOrdering::SeqCst), 3);
2985        crate::circuit_breaker::global_registry().clear();
2986    }
2987
2988    // RED-PHASE TEST 41: SharedReqwestClient round-trips through AppState extensions.
2989    #[test]
2990    fn shared_reqwest_client_ext_round_trips() {
2991        let ext = SharedReqwestClient {
2992            client: reqwest::Client::new(),
2993            timeout_secs: 30,
2994        };
2995        let state = crate::AppState::for_test();
2996        state.insert_extension(ext);
2997        let retrieved = state.extension::<SharedReqwestClient>();
2998        assert!(retrieved.is_some());
2999    }
3000
3001    // RED-PHASE TEST 42: Client::head() compiles and builds a HEAD RequestBuilder.
3002    #[test]
3003    fn client_head_method_builds_request_builder() {
3004        let client = Client::new();
3005        let _builder = client.head("https://example.com/resource");
3006    }
3007
3008    // RED-PHASE TEST 43: from_state reuses the SharedReqwestClient when registered.
3009    // Spins up a local echo server that returns the User-Agent header as the body,
3010    // then asserts the extracted Client carries the distinctive user-agent we set
3011    // on the shared inner client — proving from_state cloned it rather than
3012    // building a fresh default.
3013    #[tokio::test]
3014    #[allow(clippy::await_holding_lock)]
3015    async fn from_state_reuses_shared_client() {
3016        use axum::{Router, routing::get};
3017
3018        let _lock = crate::circuit_breaker::TEST_LOCK
3019            .lock()
3020            .unwrap_or_else(std::sync::PoisonError::into_inner);
3021        crate::circuit_breaker::global_registry().clear();
3022
3023        let app = Router::new().route(
3024            "/ua",
3025            get(|req: axum::http::Request<axum::body::Body>| async move {
3026                req.headers()
3027                    .get("user-agent")
3028                    .and_then(|v| v.to_str().ok())
3029                    .unwrap_or("")
3030                    .to_owned()
3031            }),
3032        );
3033        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3034        let addr = listener.local_addr().unwrap();
3035        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3036
3037        let distinctive_inner = reqwest::ClientBuilder::new()
3038            .user_agent("autumn-shared-pool-test")
3039            .build()
3040            .expect("failed to build inner client");
3041        let state = crate::AppState::for_test();
3042        state.insert_extension(SharedReqwestClient {
3043            client: distinctive_inner,
3044            timeout_secs: 30,
3045        });
3046
3047        let client = Client::from_state(&state);
3048        let resp = client
3049            .get(format!("http://127.0.0.1:{}/ua", addr.port()))
3050            .send()
3051            .await
3052            .expect("request should succeed");
3053
3054        assert_eq!(resp.text(), "autumn-shared-pool-test");
3055        crate::circuit_breaker::global_registry().clear();
3056    }
3057
3058    #[cfg(feature = "http-client")]
3059    #[test]
3060    fn from_state_falls_back_when_timeout_mismatches_shared_client() {
3061        // SharedReqwestClient was built with 5s; config says 10s → mismatch
3062        // → from_state must not reuse the shared inner (falls through to
3063        //   from_config which builds a fresh reqwest::Client).
3064        use crate::config::{AutumnConfig, HttpClientConfig};
3065        use std::sync::Arc;
3066
3067        let mut config = AutumnConfig::default();
3068        config.http.client = HttpClientConfig {
3069            timeout_secs: 10,
3070            ..Default::default()
3071        };
3072
3073        let state = crate::AppState::for_test();
3074        state.insert_extension(SharedReqwestClient {
3075            client: reqwest::Client::new(),
3076            timeout_secs: 5, // deliberately different from config
3077        });
3078        state.insert_extension(Arc::new(config));
3079
3080        // Should not panic — falls back to building a fresh client.
3081        let _client = Client::from_state(&state);
3082    }
3083
3084    #[cfg(feature = "http-client")]
3085    #[test]
3086    fn from_state_reuses_shared_client_when_no_config() {
3087        // No HttpConfig/AutumnConfig in state, but SharedReqwestClient is
3088        // present → hits the (None, Some(inner)) arm → with_inner.
3089        let state = crate::AppState::for_test();
3090        // Default timeout_secs from HttpClientConfig matches the default used
3091        // in effective_timeout_secs, so the shared client is reused.
3092        let default_timeout = crate::config::HttpClientConfig::default().timeout_secs;
3093        state.insert_extension(SharedReqwestClient {
3094            client: reqwest::Client::new(),
3095            timeout_secs: default_timeout,
3096        });
3097
3098        let _client = Client::from_state(&state);
3099    }
3100
3101    // ── Security-hardening tests (#1238 redirects, #1239 SSRF/pinning) ────────
3102
3103    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3104
3105    // TEST 44: SSRF address policy — blocked ranges.
3106    #[test]
3107    fn ssrf_policy_blocks_private_and_reserved_ipv4() {
3108        let blocked = [
3109            "0.0.0.0",
3110            "10.1.2.3",
3111            "100.64.0.1",      // CGNAT
3112            "127.0.0.1",       // loopback
3113            "169.254.169.254", // cloud metadata
3114            "172.16.5.4",      // private
3115            "192.0.0.1",       // IETF
3116            "192.0.2.5",       // TEST-NET-1
3117            "192.88.99.1",     // 6to4 anycast relay
3118            "192.168.1.1",     // private
3119            "198.18.0.1",      // benchmarking
3120            "198.51.100.7",    // TEST-NET-2
3121            "203.0.113.9",     // TEST-NET-3
3122            "224.0.0.1",       // multicast
3123            "240.0.0.1",       // reserved
3124            "255.255.255.255", // broadcast
3125        ];
3126        for s in blocked {
3127            let ip: IpAddr = s.parse().unwrap();
3128            assert!(is_blocked_ip(ip), "{s} should be blocked");
3129            assert!(!is_public_ip(ip), "{s} should not be public");
3130        }
3131    }
3132
3133    // TEST 45: SSRF address policy — public IPv4 is allowed.
3134    #[test]
3135    fn ssrf_policy_allows_public_ipv4() {
3136        for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34"] {
3137            let ip: IpAddr = s.parse().unwrap();
3138            assert!(is_public_ip(ip), "{s} should be public");
3139            assert!(!is_blocked_ip(ip), "{s} should not be blocked");
3140        }
3141    }
3142
3143    // TEST 46: SSRF address policy — IPv6 blocked ranges, mapped/compatible forms.
3144    #[test]
3145    fn ssrf_policy_ipv6_and_mapped_forms() {
3146        let blocked = [
3147            "::",                     // unspecified
3148            "::1",                    // loopback
3149            "fe80::1",                // link-local
3150            "fc00::1",                // ULA
3151            "ff02::1",                // multicast
3152            "2001:db8::1",            // documentation
3153            "fec0::1",                // deprecated site-local
3154            "::ffff:169.254.169.254", // IPv4-mapped metadata
3155            "::ffff:127.0.0.1",       // IPv4-mapped loopback
3156            // Remaining IANA special-purpose prefixes (Globally Reachable = False).
3157            "100::1",          // Discard-Only 100::/64 (RFC 6666)
3158            "100::dead:beef",  // Discard-Only 100::/64 (RFC 6666)
3159            "2001:2::1",       // Benchmarking 2001:2::/48 (RFC 5180)
3160            "2001:10::1",      // ORCHID 2001:10::/28 (RFC 4843)
3161            "2001:20::1",      // ORCHIDv2 2001:20::/28 (RFC 7343)
3162            "2001:20:abcd::1", // ORCHIDv2 within /28 (RFC 7343)
3163            "2001:2f::1",      // ORCHIDv2 top of /28 (s[1]=0x002f, RFC 7343)
3164            "3fff::1",         // Documentation 3fff::/20 (RFC 9637)
3165            "3fff:0fff::1",    // Documentation top of /20 (s[1]=0x0fff, RFC 9637)
3166            "5f00::1",         // SRv6 SIDs 5f00::/16 (RFC 9602)
3167            "5f00:1234::1",    // SRv6 SIDs within /16 (RFC 9602)
3168            "2620:4f:8000::1", // Direct Delegation AS112 (RFC 7534)
3169        ];
3170        for s in blocked {
3171            let ip: IpAddr = s.parse().unwrap();
3172            assert!(is_blocked_ip(ip), "{s} should be blocked");
3173        }
3174        // IPv4-compatible ::7f00:1 == 127.0.0.1 (deprecated form) is blocked.
3175        let compat = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7f00, 0x0001));
3176        assert!(
3177            is_blocked_ip(compat),
3178            "::7f00:1 (127.0.0.1) should be blocked"
3179        );
3180
3181        // A real public IPv6 (Cloudflare DNS) is allowed.
3182        let public: IpAddr = "2606:4700:4700::1111".parse().unwrap();
3183        assert!(
3184            is_public_ip(public),
3185            "2606:4700:4700::1111 should be public"
3186        );
3187
3188        // Negative tests: real public addresses adjacent to the newly-blocked
3189        // special-purpose prefixes must stay allowed (no over-blocking).
3190        let public_addrs = [
3191            "2001:4860:4860::8888", // Google DNS
3192            "2606:4700:4700::1111", // Cloudflare DNS
3193            "2400:cb00:2048::1",    // public 2400 (Cloudflare)
3194            "2620:0:2d0:200::7",    // public 2620 NOT in AS112 2620:4f:8000::/48
3195            "2001:2:1::1",          // just outside benchmarking /48 (s[2]=1, not ORCHID)
3196            "3fff:abcd::1",         // outside documentation /20 (s[1]=0xabcd > 0x0fff)
3197            "4000::1",              // outside documentation 3fff::/20
3198            "5e00::1",              // outside SRv6 5f00::/16
3199            "6000::1",              // outside SRv6 5f00::/16
3200        ];
3201        for s in public_addrs {
3202            let ip: IpAddr = s.parse().unwrap();
3203            assert!(is_public_ip(ip), "{s} should be public");
3204            assert!(!is_blocked_ip(ip), "{s} should not be blocked");
3205        }
3206    }
3207
3208    // TEST 46b: SSRF policy blocks private / metadata IPv4 tunnelled inside an
3209    // IPv6 literal via NAT64 (64:ff9b::/96) and 6to4 (2002::/16), while leaving
3210    // a genuinely-public embedded IPv4 (and native public v6) public.
3211    #[test]
3212    fn ssrf_policy_blocks_tunnelled_ipv4() {
3213        let blocked = [
3214            "64:ff9b::a9fe:a9fe", // NAT64 → 169.254.169.254 (cloud metadata)
3215            "64:ff9b::7f00:1",    // NAT64 → 127.0.0.1 (loopback)
3216            // RFC 8215 local-use NAT64 `64:ff9b:1::/48` is denied outright.
3217            "64:ff9b:1::a9fe:a9fe", // local-use NAT64 → 169.254.169.254
3218            "64:ff9b:1::7f00:1",    // local-use NAT64 → 127.0.0.1
3219            "64:ff9b:1::808:808",   // local-use NAT64 embedding public 8.8.8.8: still blocked
3220            "2002:a9fe:a9fe::",     // 6to4  → 169.254.169.254
3221            "2002:7f00:1::",        // 6to4  → 127.0.0.1
3222            "2002:0a00:0001::",     // 6to4  → 10.0.0.1
3223            // SIIT IPv4-translated `::ffff:0:0:0/96` (RFC 6052): segment[4] ==
3224            // 0xffff, segment[5] == 0, IPv4 in the last 32 bits. Distinct from
3225            // IPv4-mapped `::ffff:0:0/96`, so must be decoded and re-checked.
3226            "::ffff:0:169.254.169.254", // SIIT → 169.254.169.254 (cloud metadata)
3227            "::ffff:0:127.0.0.1",       // SIIT → 127.0.0.1 (loopback)
3228        ];
3229        for s in blocked {
3230            let ip: IpAddr = s.parse().unwrap();
3231            assert!(is_blocked_ip(ip), "{s} should be blocked");
3232            assert!(!is_public_ip(ip), "{s} should not be public");
3233        }
3234
3235        // 192.88.99.0/24 (6to4 anycast relay) is blocked as a plain IPv4 literal.
3236        let anycast: IpAddr = "192.88.99.1".parse().unwrap();
3237        assert!(is_blocked_ip(anycast), "192.88.99.1 should be blocked");
3238
3239        // A 6to4 address embedding a genuinely-public IPv4 (8.8.8.8) stays
3240        // public (the embedded v4 is public, so it falls through to the native
3241        // v6 checks, which do not match 2002::/16). So does a native public v6.
3242        // A SIIT IPv4-translated address embedding a genuinely-public IPv4
3243        // (8.8.8.8) stays public — the decoded v4 is public, so it falls
3244        // through to the native v6 checks, which do not match it.
3245        for s in ["2002:0808:0808::", "2606:4700::1111", "::ffff:0:8.8.8.8"] {
3246            let ip: IpAddr = s.parse().unwrap();
3247            assert!(is_public_ip(ip), "{s} should be public");
3248            assert!(!is_blocked_ip(ip), "{s} should not be blocked");
3249        }
3250    }
3251
3252    // TEST 47: the `url` crate normalises decimal/hex IP-literal hosts to Ipv4,
3253    // so `http://2130706433/` (== 127.0.0.1) is recognised as a blocked IP.
3254    #[test]
3255    fn ssrf_policy_decimal_encoded_host_is_blocked() {
3256        for raw in [
3257            "http://2130706433/",
3258            "http://0x7f000001/",
3259            "http://127.0.0.1/",
3260        ] {
3261            let parsed = url::Url::parse(raw).unwrap();
3262            match parsed.host() {
3263                Some(url::Host::Ipv4(v4)) => {
3264                    assert_eq!(
3265                        v4,
3266                        Ipv4Addr::LOCALHOST,
3267                        "{raw} should normalise to 127.0.0.1"
3268                    );
3269                    assert!(
3270                        is_blocked_ip(IpAddr::V4(v4)),
3271                        "{raw} host should be blocked"
3272                    );
3273                }
3274                other => panic!("{raw} did not parse to an Ipv4 host: {other:?}"),
3275            }
3276        }
3277    }
3278
3279    // Small axum helper: spawn `app` on an ephemeral 127.0.0.1 port, return it.
3280    async fn spawn(app: axum::Router) -> std::net::SocketAddr {
3281        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3282        let addr = listener.local_addr().unwrap();
3283        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3284        addr
3285    }
3286
3287    fn redirect_302(location: String) -> axum::response::Response {
3288        axum::response::Response::builder()
3289            .status(302)
3290            .header("location", location)
3291            .body(axum::body::Body::empty())
3292            .unwrap()
3293    }
3294
3295    fn redirect_307(location: String) -> axum::response::Response {
3296        axum::response::Response::builder()
3297            .status(307)
3298            .header("location", location)
3299            .body(axum::body::Body::empty())
3300            .unwrap()
3301    }
3302
3303    // Build a response with an arbitrary status that carries a `Location`
3304    // header — used to prove non-followable 3xx statuses (304/300/…) are NOT
3305    // treated as redirects even when they advertise a `Location`.
3306    fn response_with_location(status: u16, location: String) -> axum::response::Response {
3307        axum::response::Response::builder()
3308            .status(status)
3309            .header("location", location)
3310            .body(axum::body::Body::empty())
3311            .unwrap()
3312    }
3313
3314    // TEST 48: no_redirect returns the 3xx verbatim without following it.
3315    #[tokio::test]
3316    async fn no_redirect_returns_3xx_unfollowed() {
3317        use axum::{Router, routing::get};
3318        let addr = spawn(Router::new().route(
3319            "/start",
3320            get(|| async { redirect_302("http://127.0.0.1:1/never".to_owned()) }),
3321        ))
3322        .await;
3323
3324        let resp = Client::new()
3325            .get(format!("http://127.0.0.1:{}/start", addr.port()))
3326            .no_redirect()
3327            .send()
3328            .await
3329            .unwrap();
3330
3331        assert_eq!(resp.status().as_u16(), 302);
3332        assert_eq!(
3333            resp.headers().get("location").and_then(|v| v.to_str().ok()),
3334            Some("http://127.0.0.1:1/never")
3335        );
3336    }
3337
3338    // TEST 48b: the non-ssrf `no_redirect()` path returns the 3xx verbatim even
3339    // when `Location` is a MALFORMED URL. That path uses reqwest's
3340    // `Policy::none()` and never parses `Location`, so a bad target cannot turn
3341    // a `no_redirect()` fetch into an error. This guards the same "don't parse
3342    // Location when not following" contract that `send_ssrf_safe` now enforces
3343    // by returning the 3xx BEFORE calling `redirect_target`.
3344    //
3345    // NOTE: the SSRF-safe equivalent — `get_ssrf_safe(url).no_redirect()`
3346    // against a listener returning a malformed `Location` — cannot be exercised
3347    // end-to-end in-sandbox: the SSRF guard denies loopback (127.0.0.1), so the
3348    // request is rejected during resolve→validate before any response is
3349    // received. This testable-layer variant documents/guards the shared
3350    // contract instead.
3351    #[tokio::test]
3352    async fn no_redirect_returns_3xx_with_malformed_location() {
3353        use axum::{Router, routing::get};
3354        let addr = spawn(Router::new().route(
3355            "/start",
3356            // `ht!tp://\bad` is not a parseable absolute URL (invalid scheme),
3357            // but it is a valid HTTP header value, so the server can emit it.
3358            get(|| async { redirect_302("ht!tp://\\bad".to_owned()) }),
3359        ))
3360        .await;
3361
3362        let resp = Client::new()
3363            .get(format!("http://127.0.0.1:{}/start", addr.port()))
3364            .no_redirect()
3365            .send()
3366            .await
3367            .expect("no_redirect() must return the 3xx even with a malformed Location");
3368
3369        assert_eq!(resp.status().as_u16(), 302);
3370        assert_eq!(
3371            resp.headers().get("location").and_then(|v| v.to_str().ok()),
3372            Some("ht!tp://\\bad")
3373        );
3374    }
3375
3376    // TEST 49: follow_redirects follows a valid chain A→B and calls the validator.
3377    #[tokio::test]
3378    async fn follow_redirects_valid_chain_calls_validator() {
3379        use axum::{Router, routing::get};
3380
3381        let b_addr = spawn(Router::new().route("/final", get(|| async { "final-body" }))).await;
3382        let b_port = b_addr.port();
3383        let a_addr = spawn(Router::new().route(
3384            "/start",
3385            get(move || async move { redirect_302(format!("http://127.0.0.1:{b_port}/final")) }),
3386        ))
3387        .await;
3388
3389        let calls = Arc::new(AtomicUsize::new(0));
3390        let calls2 = calls.clone();
3391        let resp = Client::new()
3392            .get(format!("http://127.0.0.1:{}/start", a_addr.port()))
3393            .follow_redirects(5, move |_loc| {
3394                calls2.fetch_add(1, Ordering::SeqCst);
3395                true
3396            })
3397            .send()
3398            .await
3399            .unwrap();
3400
3401        assert_eq!(resp.status().as_u16(), 200);
3402        assert_eq!(resp.text(), "final-body");
3403        assert_eq!(
3404            calls.load(Ordering::SeqCst),
3405            1,
3406            "validator called once per hop"
3407        );
3408    }
3409
3410    // TEST 50: follow_redirects rejects a redirect to a private/blocked target,
3411    // and NEVER connects to that private address. Uses the SSRF IP policy as the
3412    // validator — structurally the same guard get_ssrf_safe applies per hop.
3413    #[tokio::test]
3414    async fn follow_redirects_rejects_private_target() {
3415        use axum::{Router, routing::get};
3416        use std::sync::atomic::AtomicBool;
3417
3418        // A "private" server that must never be reached.
3419        let touched = Arc::new(AtomicBool::new(false));
3420        let touched2 = touched.clone();
3421        let priv_addr = spawn(Router::new().route(
3422            "/secret",
3423            get(move || {
3424                let t = touched2.clone();
3425                async move {
3426                    t.store(true, Ordering::SeqCst);
3427                    "SECRET"
3428                }
3429            }),
3430        ))
3431        .await;
3432        let priv_port = priv_addr.port();
3433
3434        // Public-ish entrypoint that 302s to the private loopback target.
3435        let a_addr = spawn(Router::new().route(
3436            "/start",
3437            get(
3438                move || async move { redirect_302(format!("http://127.0.0.1:{priv_port}/secret")) },
3439            ),
3440        ))
3441        .await;
3442
3443        let validator = |u: &str| -> bool {
3444            let Ok(p) = url::Url::parse(u) else {
3445                return false;
3446            };
3447            match p.host() {
3448                Some(url::Host::Ipv4(v4)) => is_public_ip(IpAddr::V4(v4)),
3449                Some(url::Host::Ipv6(v6)) => is_public_ip(IpAddr::V6(v6)),
3450                _ => true,
3451            }
3452        };
3453
3454        let result = Client::new()
3455            .get(format!("http://127.0.0.1:{}/start", a_addr.port()))
3456            .follow_redirects(5, validator)
3457            .send()
3458            .await;
3459
3460        assert!(
3461            matches!(result, Err(ClientError::RedirectRejected(_))),
3462            "expected RedirectRejected, got {result:?}"
3463        );
3464        assert!(
3465            !touched.load(Ordering::SeqCst),
3466            "the private target must never be connected to"
3467        );
3468    }
3469
3470    // TEST 51: follow_redirects with a chain longer than `max` → TooManyRedirects.
3471    #[tokio::test]
3472    async fn follow_redirects_cap_exceeded() {
3473        use axum::{Router, routing::get};
3474
3475        // /loop always redirects back to itself → infinite chain.
3476        let addr =
3477            spawn(Router::new().route("/loop", get(|| async { redirect_302("/loop".to_owned()) })))
3478                .await;
3479
3480        let result = Client::new()
3481            .get(format!("http://127.0.0.1:{}/loop", addr.port()))
3482            .follow_redirects(2, |_| true)
3483            .send()
3484            .await;
3485
3486        assert!(
3487            matches!(result, Err(ClientError::TooManyRedirects(2))),
3488            "expected TooManyRedirects(2), got {result:?}"
3489        );
3490    }
3491
3492    // TEST 52: follow_redirects(0, ..) turns the first 3xx into TooManyRedirects.
3493    #[tokio::test]
3494    async fn follow_redirects_zero_max_errors_on_first_3xx() {
3495        use axum::{Router, routing::get};
3496        let addr = spawn(Router::new().route(
3497            "/start",
3498            get(|| async { redirect_302("http://127.0.0.1:1/x".to_owned()) }),
3499        ))
3500        .await;
3501
3502        let result = Client::new()
3503            .get(format!("http://127.0.0.1:{}/start", addr.port()))
3504            .follow_redirects(0, |_| true)
3505            .send()
3506            .await;
3507
3508        assert!(matches!(result, Err(ClientError::TooManyRedirects(0))));
3509    }
3510
3511    // TEST 53: pin_to bypasses DNS and connects to the URL's port.
3512    //
3513    // The host `pinned.invalid` is guaranteed non-resolvable (.invalid TLD), yet
3514    // pinning to 127.0.0.1 reaches the listener — proving DNS was bypassed. The
3515    // pinned SocketAddr uses port 1 (which nothing listens on) while the URL uses
3516    // the real listener port; reaching the listener proves reqwest IGNORES the
3517    // resolve SocketAddr's port and connects to the URL's port instead.
3518    #[tokio::test]
3519    async fn pin_to_bypasses_dns_and_uses_url_port() {
3520        use axum::{Router, routing::get};
3521        let addr = spawn(Router::new().route("/ping", get(|| async { "pong" }))).await;
3522        let listener_port = addr.port();
3523
3524        let resp = Client::new()
3525            .get(format!("http://pinned.invalid:{listener_port}/ping"))
3526            // Deliberately-wrong port (1) to probe reqwest's port handling.
3527            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1))
3528            .send()
3529            .await
3530            .expect("pinned request should reach the loopback listener");
3531
3532        assert_eq!(resp.status().as_u16(), 200);
3533        assert_eq!(resp.text(), "pong");
3534    }
3535
3536    // TEST 54: get_ssrf_safe rejects a host that resolves to a blocked IP BEFORE
3537    // connecting. `localhost` resolves to 127.0.0.1 (and/or ::1), both blocked.
3538    // The listener's handler must never fire.
3539    #[tokio::test]
3540    async fn get_ssrf_safe_rejects_loopback_host_before_connecting() {
3541        use axum::{Router, routing::get};
3542        use std::sync::atomic::AtomicBool;
3543
3544        let touched = Arc::new(AtomicBool::new(false));
3545        let touched2 = touched.clone();
3546        let addr = spawn(Router::new().route(
3547            "/x",
3548            get(move || {
3549                let t = touched2.clone();
3550                async move {
3551                    t.store(true, Ordering::SeqCst);
3552                    "reached"
3553                }
3554            }),
3555        ))
3556        .await;
3557
3558        let result = Client::new()
3559            .get_ssrf_safe(format!("http://localhost:{}/x", addr.port()))
3560            .send()
3561            .await;
3562
3563        assert!(
3564            matches!(result, Err(ClientError::SsrfBlocked(_))),
3565            "expected SsrfBlocked, got {result:?}"
3566        );
3567        assert!(
3568            !touched.load(Ordering::SeqCst),
3569            "SSRF guard must reject before any connection"
3570        );
3571    }
3572
3573    // TEST 55: get_ssrf_safe rejects a decimal-encoded loopback IP literal
3574    // (http://2130706433/ == 127.0.0.1) with no DNS lookup.
3575    #[tokio::test]
3576    async fn get_ssrf_safe_rejects_decimal_encoded_loopback() {
3577        let result = Client::new()
3578            .get_ssrf_safe("http://2130706433/")
3579            .send()
3580            .await;
3581        assert!(
3582            matches!(result, Err(ClientError::SsrfBlocked(_))),
3583            "expected SsrfBlocked, got {result:?}"
3584        );
3585    }
3586
3587    // TEST 56: resolve_and_validate — the resolve→validate core of get_ssrf_safe.
3588    // A public IP literal validates (returning the URL's port); blocked literals
3589    // and decimal-encoded loopback are rejected with SsrfBlocked. Exercising the
3590    // actual network connect of the safe path against a public host is NOT
3591    // reproducible in-sandbox (no reachable public server); it is covered
3592    // structurally by the pin_to and follow_redirects tests. See the report.
3593    #[tokio::test]
3594    async fn resolve_and_validate_accepts_public_rejects_blocked() {
3595        // Public literal with an explicit port → Ok, single-element validated
3596        // set, port preserved.
3597        let ok = resolve_and_validate("http://8.8.8.8:8080/path")
3598            .await
3599            .unwrap();
3600        assert_eq!(
3601            ok,
3602            vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 8080)]
3603        );
3604
3605        // https default port is inferred.
3606        let ok_https = resolve_and_validate("https://1.1.1.1/").await.unwrap();
3607        assert_eq!(ok_https.len(), 1);
3608        assert_eq!(ok_https[0].port(), 443);
3609
3610        // Blocked literals and decimal-encoded loopback → SsrfBlocked.
3611        for raw in [
3612            "http://127.0.0.1/",
3613            "http://169.254.169.254/latest/meta-data/",
3614            "http://10.0.0.1/",
3615            "http://2130706433/",
3616        ] {
3617            let err = resolve_and_validate(raw).await;
3618            assert!(
3619                matches!(err, Err(ClientError::SsrfBlocked(_))),
3620                "{raw} should be SsrfBlocked, got {err:?}"
3621            );
3622        }
3623    }
3624
3625    // TEST 56b: validate_resolved_addrs — the pure validation core shared by the
3626    // resolve→validate step. A set of public addrs returns ALL of them in order;
3627    // a set mixing a public and a blocked addr is rejected with SsrfBlocked; an
3628    // all-public IPv6+IPv4 mix returns everything in order. This exercises the
3629    // multi-record path (pin to ALL validated addresses) without needing real
3630    // multi-record DNS.
3631    #[test]
3632    fn validate_resolved_addrs_returns_all_public_rejects_any_blocked() {
3633        let v4 = |a, b, c, d, p| SocketAddr::new(IpAddr::V4(Ipv4Addr::new(a, b, c, d)), p);
3634
3635        // Two public addrs → Ok with BOTH returned, order preserved.
3636        let two = vec![v4(1, 1, 1, 1, 443), v4(8, 8, 8, 8, 443)];
3637        assert_eq!(validate_resolved_addrs(two.clone()).unwrap(), two);
3638
3639        // Public + blocked (10.0.0.1, RFC1918) → Err(SsrfBlocked).
3640        let mixed = vec![v4(1, 1, 1, 1, 443), v4(10, 0, 0, 1, 443)];
3641        assert!(
3642            matches!(
3643                validate_resolved_addrs(mixed),
3644                Err(ClientError::SsrfBlocked(_))
3645            ),
3646            "a set containing a blocked address must be rejected"
3647        );
3648
3649        // All-public IPv6 (2606:4700:4700::1111, Cloudflare) + IPv4 mix → Ok,
3650        // all preserved in order.
3651        let v6 = SocketAddr::new(
3652            IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111)),
3653            443,
3654        );
3655        let mix = vec![v6, v4(8, 8, 8, 8, 443)];
3656        assert_eq!(validate_resolved_addrs(mix.clone()).unwrap(), mix);
3657    }
3658
3659    // TEST 57: pin_to alone does NOT auto-follow a cross-host redirect. reqwest
3660    // would otherwise re-resolve the new host via normal DNS, silently escaping
3661    // the pin. The 302 must be returned verbatim and the onward target must
3662    // never be connected to.
3663    #[tokio::test]
3664    async fn pin_to_does_not_follow_redirect_unpinned() {
3665        use axum::{Router, routing::get};
3666        use std::sync::atomic::AtomicBool;
3667
3668        // The redirect target that must never be reached.
3669        let touched = Arc::new(AtomicBool::new(false));
3670        let touched2 = touched.clone();
3671        let onward = spawn(Router::new().route(
3672            "/onward",
3673            get(move || {
3674                let t = touched2.clone();
3675                async move {
3676                    t.store(true, Ordering::SeqCst);
3677                    "REACHED"
3678                }
3679            }),
3680        ))
3681        .await;
3682        let onward_port = onward.port();
3683
3684        let start =
3685            spawn(Router::new().route(
3686                "/start",
3687                get(move || async move {
3688                    redirect_302(format!("http://127.0.0.1:{onward_port}/onward"))
3689                }),
3690            ))
3691            .await;
3692        let start_port = start.port();
3693
3694        // Use a DOMAIN host (`pinned.invalid`) so the pin's resolve override is
3695        // actually consulted; pinning an IP-literal host is now rejected by the
3696        // PinRequiresDomainHost guard. The non-resolvable domain reaches the
3697        // loopback listener only via the pin.
3698        let resp = Client::new()
3699            .get(format!("http://pinned.invalid:{start_port}/start"))
3700            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), start_port))
3701            .send()
3702            .await
3703            .expect("pinned request should return the 302 unfollowed");
3704
3705        assert_eq!(
3706            resp.status().as_u16(),
3707            302,
3708            "pin_to must return the redirect unfollowed"
3709        );
3710        assert!(
3711            !touched.load(Ordering::SeqCst),
3712            "pin_to must not silently follow the redirect onward"
3713        );
3714    }
3715
3716    // TEST 58: get_ssrf_safe rejects a non-http(s) scheme up front with
3717    // InvalidUrl, before any DNS resolution or connection.
3718    #[tokio::test]
3719    async fn get_ssrf_safe_rejects_non_http_scheme() {
3720        for raw in ["ftp://public.example/resource", "gopher://public.example/"] {
3721            let result = Client::new().get_ssrf_safe(raw).send().await;
3722            assert!(
3723                matches!(result, Err(ClientError::InvalidUrl(_))),
3724                "{raw} should be rejected with InvalidUrl, got {result:?}"
3725            );
3726        }
3727    }
3728
3729    // TEST 59: a cross-origin redirect (different port ⇒ different origin) must
3730    // NOT forward credential-bearing request headers to the new origin. This is
3731    // the manual-redirect-loop version of reqwest's built-in strip-on-cross-host
3732    // behaviour, closing a credential-leak hole (Fix A).
3733    #[tokio::test]
3734    async fn follow_redirects_strips_sensitive_headers_cross_origin() {
3735        use axum::{Router, routing::get};
3736
3737        let seen: Arc<Mutex<Option<HeaderMap>>> = Arc::new(Mutex::new(None));
3738        let seen2 = seen.clone();
3739        // Listener B records the headers it received (different port = different
3740        // origin from A).
3741        let b_addr = spawn(Router::new().route(
3742            "/dst",
3743            get(move |headers: HeaderMap| {
3744                let slot = seen2.clone();
3745                async move {
3746                    *slot.lock().unwrap() = Some(headers);
3747                    "ok"
3748                }
3749            }),
3750        ))
3751        .await;
3752        let b_port = b_addr.port();
3753
3754        // Listener A 302-redirects onto B.
3755        let a_addr = spawn(Router::new().route(
3756            "/",
3757            get(move || async move { redirect_302(format!("http://127.0.0.1:{b_port}/dst")) }),
3758        ))
3759        .await;
3760
3761        let resp = Client::new()
3762            .get(format!("http://127.0.0.1:{}/", a_addr.port()))
3763            .header("authorization", "secret")
3764            .header("cookie", "session=abc")
3765            .header("proxy-authorization", "Basic zzz")
3766            .follow_redirects(3, |_| true)
3767            .send()
3768            .await
3769            .unwrap();
3770
3771        assert_eq!(resp.status().as_u16(), 200);
3772        let headers = seen
3773            .lock()
3774            .unwrap()
3775            .clone()
3776            .expect("listener B must have been reached");
3777        assert!(
3778            headers.get("authorization").is_none(),
3779            "authorization must be stripped on a cross-origin redirect"
3780        );
3781        assert!(
3782            headers.get("cookie").is_none(),
3783            "cookie must be stripped on a cross-origin redirect"
3784        );
3785        assert!(
3786            headers.get("proxy-authorization").is_none(),
3787            "proxy-authorization must be stripped on a cross-origin redirect"
3788        );
3789    }
3790
3791    // TEST 60: a SAME-origin redirect (relative `Location`, same host:port) must
3792    // keep credential-bearing headers — stripping only applies across origins.
3793    #[tokio::test]
3794    async fn follow_redirects_keeps_sensitive_headers_same_origin() {
3795        use axum::{Router, routing::get};
3796
3797        let seen: Arc<Mutex<Option<HeaderMap>>> = Arc::new(Mutex::new(None));
3798        let seen2 = seen.clone();
3799        let addr = spawn(
3800            Router::new()
3801                .route("/", get(|| async { redirect_302("/next".to_owned()) }))
3802                .route(
3803                    "/next",
3804                    get(move |headers: HeaderMap| {
3805                        let slot = seen2.clone();
3806                        async move {
3807                            *slot.lock().unwrap() = Some(headers);
3808                            "ok"
3809                        }
3810                    }),
3811                ),
3812        )
3813        .await;
3814
3815        let resp = Client::new()
3816            .get(format!("http://127.0.0.1:{}/", addr.port()))
3817            .header("authorization", "secret")
3818            .follow_redirects(3, |_| true)
3819            .send()
3820            .await
3821            .unwrap();
3822
3823        assert_eq!(resp.status().as_u16(), 200);
3824        let headers = seen
3825            .lock()
3826            .unwrap()
3827            .clone()
3828            .expect("/next must have been reached");
3829        assert_eq!(
3830            headers.get("authorization").and_then(|v| v.to_str().ok()),
3831            Some("secret"),
3832            "authorization must be preserved on a same-origin redirect"
3833        );
3834    }
3835
3836    // TEST 61: RFC 7231 §6.4.3 — a 302 in response to a POST rewrites the next
3837    // hop to a bodyless GET (Fix B).
3838    #[tokio::test]
3839    async fn follow_redirects_302_post_becomes_get() {
3840        use axum::{
3841            Router,
3842            routing::{any, post},
3843        };
3844
3845        let seen: Arc<Mutex<Option<(String, HeaderMap, Bytes)>>> = Arc::new(Mutex::new(None));
3846        let seen2 = seen.clone();
3847        // B records the method + headers + body it actually received, for any verb.
3848        let b_addr = spawn(Router::new().route(
3849            "/dst",
3850            any(move |method: Method, headers: HeaderMap, body: Bytes| {
3851                let slot = seen2.clone();
3852                async move {
3853                    *slot.lock().unwrap() = Some((method.to_string(), headers, body));
3854                    "ok"
3855                }
3856            }),
3857        ))
3858        .await;
3859        let b_port = b_addr.port();
3860
3861        let a_addr = spawn(Router::new().route(
3862            "/",
3863            post(move || async move { redirect_302(format!("http://127.0.0.1:{b_port}/dst")) }),
3864        ))
3865        .await;
3866
3867        // `.json(..)` sets a request body AND `Content-Type: application/json`.
3868        // On the 302 POST→GET rewrite the body is dropped, and the payload
3869        // headers must be dropped with it (Fix B) — otherwise the followed GET
3870        // would carry a misleading `Content-Type` for a body it no longer has.
3871        let resp = Client::new()
3872            .post(format!("http://127.0.0.1:{}/", a_addr.port()))
3873            .json(&serde_json::json!({"payload": true}))
3874            .follow_redirects(3, |_| true)
3875            .send()
3876            .await
3877            .unwrap();
3878
3879        assert_eq!(resp.status().as_u16(), 200);
3880        let (method, headers, body) = seen
3881            .lock()
3882            .unwrap()
3883            .clone()
3884            .expect("listener B must have been reached");
3885        assert_eq!(method, "GET", "302 must rewrite POST → GET");
3886        assert!(body.is_empty(), "302 POST→GET must drop the request body");
3887        assert!(
3888            !headers.contains_key(reqwest::header::CONTENT_TYPE),
3889            "302 POST→GET must drop the Content-Type payload header"
3890        );
3891        assert!(
3892            !headers.contains_key(reqwest::header::CONTENT_LENGTH),
3893            "302 POST→GET must drop the Content-Length payload header"
3894        );
3895    }
3896
3897    // TEST 62: RFC 7231 §6.4.7 — a 307 preserves BOTH the method and the body
3898    // across the redirect (the review bot's drop-body-every-hop snippet was
3899    // wrong here) (Fix B).
3900    #[tokio::test]
3901    async fn follow_redirects_307_preserves_method_and_body() {
3902        use axum::{
3903            Router,
3904            routing::{any, post},
3905        };
3906
3907        let seen: Arc<Mutex<Option<(String, Bytes)>>> = Arc::new(Mutex::new(None));
3908        let seen2 = seen.clone();
3909        let b_addr = spawn(Router::new().route(
3910            "/dst",
3911            any(move |method: Method, body: Bytes| {
3912                let slot = seen2.clone();
3913                async move {
3914                    *slot.lock().unwrap() = Some((method.to_string(), body));
3915                    "ok"
3916                }
3917            }),
3918        ))
3919        .await;
3920        let b_port = b_addr.port();
3921
3922        let a_addr = spawn(Router::new().route(
3923            "/",
3924            post(move || async move { redirect_307(format!("http://127.0.0.1:{b_port}/dst")) }),
3925        ))
3926        .await;
3927
3928        let resp = Client::new()
3929            .post(format!("http://127.0.0.1:{}/", a_addr.port()))
3930            .text_body("payload")
3931            .follow_redirects(3, |_| true)
3932            .send()
3933            .await
3934            .unwrap();
3935
3936        assert_eq!(resp.status().as_u16(), 200);
3937        let (method, body) = seen
3938            .lock()
3939            .unwrap()
3940            .clone()
3941            .expect("listener B must have been reached");
3942        assert_eq!(method, "POST", "307 must preserve the POST method");
3943        assert_eq!(
3944            &body[..],
3945            b"payload",
3946            "307 must preserve the request body verbatim"
3947        );
3948    }
3949
3950    // TEST 63: get_ssrf_safe derives its redirect follow/cap from the chained
3951    // builder mode via ssrf_redirect_plan, so `no_redirect()` /
3952    // `follow_redirects(max, ..)` override the SSRF-safe default hop cap.
3953    //
3954    // NOTE: the network-level follow behaviour of get_ssrf_safe cannot be
3955    // exercised in-sandbox — the only reachable address here is loopback, which
3956    // the SSRF guard blocks before connecting — so this deterministic unit test
3957    // on the factored `ssrf_redirect_plan` helper stands in for it.
3958    #[test]
3959    fn ssrf_redirect_plan_honours_chained_override() {
3960        let client = Client::new();
3961
3962        // Default get_ssrf_safe → follow up to the SSRF-safe hop cap.
3963        let default = client.get_ssrf_safe("https://example.com/");
3964        assert_eq!(
3965            default.ssrf_redirect_plan(),
3966            (true, SSRF_SAFE_MAX_REDIRECTS),
3967            "default SSRF-safe path follows up to SSRF_SAFE_MAX_REDIRECTS"
3968        );
3969
3970        // no_redirect() → do NOT follow.
3971        let none = client.get_ssrf_safe("https://example.com/").no_redirect();
3972        let (follow, _max) = none.ssrf_redirect_plan();
3973        assert!(
3974            !follow,
3975            "no_redirect() must disable following on the safe path"
3976        );
3977
3978        // follow_redirects(3, ..) → follow up to the caller's max.
3979        let follow3 = client
3980            .get_ssrf_safe("https://example.com/")
3981            .follow_redirects(3, |_| true);
3982        assert_eq!(
3983            follow3.ssrf_redirect_plan(),
3984            (true, 3),
3985            "follow_redirects(3, ..) must cap the safe path at 3 hops"
3986        );
3987    }
3988
3989    // TEST 64: pin_to + follow_redirects is rejected at send time with
3990    // IncompatiblePinRedirect — deterministically, without touching the network.
3991    // A single pinned SocketAddr only covers hop 0; later redirect hops re-resolve
3992    // via normal DNS, so following would silently escape the pin.
3993    #[tokio::test]
3994    async fn pin_then_follow_redirects_is_rejected() {
3995        let result = Client::new()
3996            .get("http://example.com/")
3997            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
3998            .follow_redirects(2, |_| true)
3999            .send()
4000            .await;
4001
4002        assert!(
4003            matches!(result, Err(ClientError::IncompatiblePinRedirect(_))),
4004            "expected IncompatiblePinRedirect, got {result:?}"
4005        );
4006    }
4007
4008    // TEST 65: the rejection is order-independent — chaining follow_redirects
4009    // before pin_to produces the same IncompatiblePinRedirect error.
4010    #[tokio::test]
4011    async fn follow_redirects_then_pin_is_rejected() {
4012        let result = Client::new()
4013            .get("http://example.com/")
4014            .follow_redirects(2, |_| true)
4015            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
4016            .send()
4017            .await;
4018
4019        assert!(
4020            matches!(result, Err(ClientError::IncompatiblePinRedirect(_))),
4021            "expected IncompatiblePinRedirect, got {result:?}"
4022        );
4023    }
4024
4025    // TEST 66: pin_to combined with no_redirect is NOT affected by the guard —
4026    // the request proceeds and returns the 3xx verbatim (RedirectMode::None).
4027    #[tokio::test]
4028    async fn pin_with_no_redirect_is_allowed() {
4029        use axum::{Router, routing::get};
4030
4031        let addr = spawn(Router::new().route(
4032            "/start",
4033            get(|| async { redirect_302("http://127.0.0.1:1/onward".to_owned()) }),
4034        ))
4035        .await;
4036        let port = addr.port();
4037
4038        // Use a DOMAIN host so the pin's resolve override is consulted; pinning
4039        // an IP-literal host is now rejected by the PinRequiresDomainHost guard.
4040        let resp = Client::new()
4041            .get(format!("http://pinned.invalid:{port}/start"))
4042            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port))
4043            .no_redirect()
4044            .send()
4045            .await
4046            .expect("pin_to + no_redirect must return the 3xx unfollowed, not error");
4047
4048        assert_eq!(
4049            resp.status().as_u16(),
4050            302,
4051            "pin_to + no_redirect returns the redirect verbatim"
4052        );
4053    }
4054
4055    // TEST 67: pin_to on an IPv4-literal URL host is rejected at send time with
4056    // PinRequiresDomainHost — deterministically, without touching the network.
4057    // reqwest/hyper treat an IP-literal host as already-resolved and skip the
4058    // resolve override that installs the pin, so the socket would connect to the
4059    // literal in the URL (198.51.100.1), NOT the pinned 127.0.0.1 — silently
4060    // bypassing the pin. The guard rejects it instead.
4061    #[tokio::test]
4062    async fn pin_to_ipv4_literal_host_is_rejected() {
4063        let result = Client::new()
4064            .get("http://198.51.100.1:8080/")
4065            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
4066            .send()
4067            .await;
4068
4069        assert!(
4070            matches!(result, Err(ClientError::PinRequiresDomainHost(_))),
4071            "expected PinRequiresDomainHost, got {result:?}"
4072        );
4073    }
4074
4075    // TEST 68: the same rejection applies to an IPv6-literal URL host.
4076    #[tokio::test]
4077    async fn pin_to_ipv6_literal_host_is_rejected() {
4078        let result = Client::new()
4079            .get("http://[2606:4700::1111]/")
4080            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
4081            .send()
4082            .await;
4083
4084        assert!(
4085            matches!(result, Err(ClientError::PinRequiresDomainHost(_))),
4086            "expected PinRequiresDomainHost, got {result:?}"
4087        );
4088    }
4089
4090    // TEST 68b: get_ssrf_safe combined with pin_to is rejected at send time with
4091    // PinNotAllowedWithSsrfSafe — deterministically, without touching the
4092    // network. The SSRF-safe path runs its own per-hop resolve/validate/pin and
4093    // never reads the pin_to address, so an explicit pin would be silently
4094    // ignored; the guard fails loudly instead.
4095    #[tokio::test]
4096    async fn get_ssrf_safe_with_pin_to_is_rejected() {
4097        let result = Client::new()
4098            .get_ssrf_safe("http://example.com/")
4099            .pin_to(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
4100            .send()
4101            .await;
4102
4103        assert!(
4104            matches!(result, Err(ClientError::PinNotAllowedWithSsrfSafe(_))),
4105            "expected PinNotAllowedWithSsrfSafe, got {result:?}"
4106        );
4107    }
4108
4109    // TEST 69: a `304 Not Modified` that happens to carry a `Location` header is
4110    // NOT a followable redirect. reqwest only follows 301/302/303/307/308, so
4111    // `redirect_target` must return the 304 to the caller verbatim rather than
4112    // issuing a second request against the `Location` target.
4113    #[tokio::test]
4114    async fn follow_redirects_does_not_follow_304_with_location() {
4115        use axum::{Router, routing::get};
4116        use std::sync::atomic::AtomicBool;
4117
4118        // Listener B must never be reached.
4119        let touched = Arc::new(AtomicBool::new(false));
4120        let touched2 = touched.clone();
4121        let b_addr = spawn(Router::new().route(
4122            "/dst",
4123            get(move || {
4124                let t = touched2.clone();
4125                async move {
4126                    t.store(true, Ordering::SeqCst);
4127                    "SHOULD-NOT-BE-HIT"
4128                }
4129            }),
4130        ))
4131        .await;
4132        let b_port = b_addr.port();
4133
4134        // Listener A returns 304 + Location pointing at B.
4135        let a_addr = spawn(Router::new().route(
4136            "/start",
4137            get(move || async move {
4138                response_with_location(304, format!("http://127.0.0.1:{b_port}/dst"))
4139            }),
4140        ))
4141        .await;
4142
4143        let resp = Client::new()
4144            .get(format!("http://127.0.0.1:{}/start", a_addr.port()))
4145            .follow_redirects(5, |_| true)
4146            .send()
4147            .await
4148            .unwrap();
4149
4150        assert_eq!(
4151            resp.status().as_u16(),
4152            304,
4153            "a 304 with a Location header must be returned verbatim, not followed"
4154        );
4155        assert!(
4156            !touched.load(Ordering::SeqCst),
4157            "the 304 Location target must never be requested"
4158        );
4159    }
4160
4161    // TEST 70: a `300 Multiple Choices` that carries a `Location` header is
4162    // likewise not a followable redirect (only 301/302/303/307/308 are), so the
4163    // 300 is returned to the caller and the `Location` target is never hit.
4164    #[tokio::test]
4165    async fn follow_redirects_does_not_follow_300_with_location() {
4166        use axum::{Router, routing::get};
4167        use std::sync::atomic::AtomicBool;
4168
4169        let touched = Arc::new(AtomicBool::new(false));
4170        let touched2 = touched.clone();
4171        let b_addr = spawn(Router::new().route(
4172            "/dst",
4173            get(move || {
4174                let t = touched2.clone();
4175                async move {
4176                    t.store(true, Ordering::SeqCst);
4177                    "SHOULD-NOT-BE-HIT"
4178                }
4179            }),
4180        ))
4181        .await;
4182        let b_port = b_addr.port();
4183
4184        let a_addr = spawn(Router::new().route(
4185            "/start",
4186            get(move || async move {
4187                response_with_location(300, format!("http://127.0.0.1:{b_port}/dst"))
4188            }),
4189        ))
4190        .await;
4191
4192        let resp = Client::new()
4193            .get(format!("http://127.0.0.1:{}/start", a_addr.port()))
4194            .follow_redirects(5, |_| true)
4195            .send()
4196            .await
4197            .unwrap();
4198
4199        assert_eq!(
4200            resp.status().as_u16(),
4201            300,
4202            "a 300 with a Location header must be returned verbatim, not followed"
4203        );
4204        assert!(
4205            !touched.load(Ordering::SeqCst),
4206            "the 300 Location target must never be requested"
4207        );
4208    }
4209}