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::sync::atomic::{AtomicUsize, Ordering};
50use std::sync::{Arc, Mutex};
51use std::time::{Duration, Instant};
52
53use bytes::Bytes;
54use reqwest::Method;
55use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
56use serde::Serialize;
57use serde::de::DeserializeOwned;
58
59// ── Error ────────────────────────────────────────────────────────────────────
60
61/// Errors produced by [`Client`] and [`RequestBuilder`].
62#[derive(Debug, thiserror::Error)]
63pub enum ClientError {
64    /// An underlying `reqwest` transport error.
65    #[error("outbound HTTP request failed: {0}")]
66    Request(#[from] reqwest::Error),
67    /// JSON (de)serialisation failed.
68    #[error("JSON error: {0}")]
69    Json(#[from] serde_json::Error),
70    /// No mock entry matched the outgoing request.
71    #[error("no mock registered for {0} {1}")]
72    NoMock(String, String),
73    /// The outbound circuit breaker is open.
74    #[error("outbound circuit breaker is open")]
75    CircuitBreakerOpen,
76}
77
78// ── Response ─────────────────────────────────────────────────────────────────
79
80/// Completed outbound HTTP response with eagerly-collected body bytes.
81///
82/// Body is consumed once — call exactly one of [`json`](Self::json),
83/// [`text`](Self::text), or [`bytes`](Self::bytes).
84pub struct Response {
85    status: reqwest::StatusCode,
86    headers: HeaderMap,
87    body: Bytes,
88    url: Option<reqwest::Url>,
89}
90
91impl Response {
92    /// HTTP status code.
93    pub const fn status(&self) -> reqwest::StatusCode {
94        self.status
95    }
96
97    /// Response headers (sensitive values are **not** redacted here).
98    pub const fn headers(&self) -> &HeaderMap {
99        &self.headers
100    }
101
102    /// `true` when the status code is in the 2xx range.
103    pub fn is_success(&self) -> bool {
104        self.status.is_success()
105    }
106
107    /// URL that was ultimately requested (after redirects, if any).
108    pub const fn url(&self) -> Option<&reqwest::Url> {
109        self.url.as_ref()
110    }
111
112    /// Deserialise the body as JSON.
113    ///
114    /// # Errors
115    /// Returns [`ClientError::Json`] if the body is not valid JSON for `T`.
116    pub fn json<T: DeserializeOwned>(self) -> Result<T, ClientError> {
117        serde_json::from_slice(&self.body).map_err(ClientError::Json)
118    }
119
120    /// Return the body as a UTF-8 string (lossy).
121    pub fn text(self) -> String {
122        String::from_utf8_lossy(&self.body).into_owned()
123    }
124
125    /// Return the raw body bytes.
126    pub fn bytes(self) -> Bytes {
127        self.body
128    }
129}
130
131// ── RetryPolicy ──────────────────────────────────────────────────────────────
132
133/// Retry configuration for a [`RequestBuilder`].
134#[derive(Clone, Debug)]
135pub struct RetryPolicy {
136    /// Maximum number of additional attempts after the first failure.  Zero
137    /// means no retries (one attempt total).
138    pub max_retries: u32,
139    /// When `true` (the default), only GET / HEAD / PUT / DELETE / OPTIONS /
140    /// TRACE are retried; POST and PATCH are not.
141    pub retry_idempotent_only: bool,
142    /// Maximum Retry-After sleep duration to accept before clamping.
143    pub max_retry_after: Duration,
144    /// Per-request timeout.
145    pub request_timeout: Option<Duration>,
146}
147
148impl Default for RetryPolicy {
149    fn default() -> Self {
150        Self {
151            max_retries: 3,
152            retry_idempotent_only: true,
153            max_retry_after: Duration::from_secs(10),
154            request_timeout: Some(Duration::from_secs(30)),
155        }
156    }
157}
158
159// ── MockRegistry ─────────────────────────────────────────────────────────────
160
161/// Internal mock entry stored by [`MockRegistry`].
162pub(crate) struct MockEntry {
163    pub(crate) method: Option<Method>,
164    /// URL path to match against the path component of the outbound URL.
165    pub(crate) path: String,
166    /// Optional alias that must match the `Client`'s alias.
167    pub(crate) alias: Option<String>,
168    pub(crate) status: u16,
169    pub(crate) body: Option<serde_json::Value>,
170    pub(crate) call_count: Arc<AtomicUsize>,
171}
172
173/// Canned response returned by a [`MockRegistry`] match.
174pub(crate) struct MockResponse {
175    pub(crate) status: u16,
176    pub(crate) body: Option<serde_json::Value>,
177}
178
179/// In-process mock registry used by [`TestApp::http_mock`](crate::test::TestApp::http_mock).
180///
181/// Stored in [`AppState`](crate::AppState) extensions during test builds so
182/// that any [`Client`] extracted from state will intercept matching requests
183/// and return canned responses without hitting the network.
184pub struct MockRegistry {
185    entries: Mutex<Vec<MockEntry>>,
186}
187
188impl MockRegistry {
189    /// Create an empty registry.
190    #[must_use]
191    pub const fn new() -> Self {
192        Self {
193            entries: Mutex::new(Vec::new()),
194        }
195    }
196
197    /// Register a new mock entry.
198    pub(crate) fn register(&self, entry: MockEntry) {
199        self.entries
200            .lock()
201            .expect("mock registry lock poisoned")
202            .push(entry);
203    }
204
205    /// Find the first entry matching `(method, url, alias)` and increment its
206    /// call counter.  Returns `None` when no entry matches.
207    pub(crate) fn find_match(
208        &self,
209        method: &Method,
210        url: &str,
211        alias: Option<&str>,
212    ) -> Option<MockResponse> {
213        // Extract the URL path component for matching, stripping query and fragment.
214        // For full URLs (https://…) reqwest::Url::parse gives us the clean path.
215        // For relative paths we strip manually so "?query" doesn't break matching.
216        // Extract the path without query/fragment. For full URLs reqwest parses
217        // cleanly; for relative strings we strip manually.
218        let url_path_owned: String = reqwest::Url::parse(url).map_or_else(
219            |_| {
220                let s = url.split_once('?').map_or(url, |(p, _)| p);
221                s.split_once('#').map_or(s, |(p, _)| p).to_owned()
222            },
223            |parsed| parsed.path().to_owned(),
224        );
225        let url_path = url_path_owned.as_str();
226
227        // Hold the lock only for the search; release before fetching metadata.
228        let found = {
229            let entries = self.entries.lock().expect("mock registry lock poisoned");
230            entries.iter().find_map(|entry| {
231                let method_ok = entry.method.as_ref().is_none_or(|m| m == method);
232                // Path match: exact equality OR suffix at a segment boundary.
233                // When the mock path starts with '/' the leading slash IS the
234                // segment separator, so a non-empty prefix is also valid
235                // (e.g. mock "/charges" matches URL path "/v1/charges").
236                let path_ok = url_path == entry.path.as_str()
237                    || url_path
238                        .strip_suffix(entry.path.as_str())
239                        .is_some_and(|prefix| {
240                            prefix.is_empty()
241                                || prefix.ends_with('/')
242                                || entry.path.starts_with('/')
243                        });
244                let alias_ok = entry
245                    .alias
246                    .as_deref()
247                    .is_none_or(|a| alias.is_some_and(|b| a == b));
248                if method_ok && path_ok && alias_ok {
249                    Some((entry.call_count.clone(), entry.status, entry.body.clone()))
250                } else {
251                    None
252                }
253            })
254        };
255
256        found.map(|(call_count, status, body)| {
257            call_count.fetch_add(1, Ordering::SeqCst);
258            MockResponse { status, body }
259        })
260    }
261}
262
263impl Default for MockRegistry {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// Newtype stored in [`AppState`](crate::AppState) extensions so the
270/// `MockRegistry` `Arc` survives a `build()` without double-wrapping.
271pub struct HttpMockRegistryExt(pub Arc<MockRegistry>);
272
273/// Handle returned by
274/// [`MockSetupBuilder::respond_with`] that lets tests assert call counts.
275pub struct MockHandle {
276    alias: String,
277    method: String,
278    path: String,
279    call_count: Arc<AtomicUsize>,
280}
281
282impl MockHandle {
283    /// Assert that the mocked endpoint was called exactly `expected` times.
284    ///
285    /// # Panics
286    ///
287    /// Panics with a diagnostic message when the actual call count differs.
288    pub fn expect_called(&self, expected: usize) {
289        let actual = self.call_count.load(Ordering::SeqCst);
290        assert_eq!(
291            actual, expected,
292            "http mock for {} {} {} expected {} call(s) but got {}",
293            self.alias, self.method, self.path, expected, actual,
294        );
295    }
296
297    /// Return the raw call count without asserting.
298    #[must_use]
299    pub fn call_count(&self) -> usize {
300        self.call_count.load(Ordering::SeqCst)
301    }
302}
303
304/// Builder returned by [`TestApp::http_mock`](crate::test::TestApp::http_mock).
305///
306/// Chain a method call (`get`, `post`, …) and a path, then call
307/// [`respond_with`](Self::respond_with) to register the entry and obtain a
308/// [`MockHandle`] for later assertions.
309pub struct MockSetupBuilder {
310    pub(crate) registry: Arc<MockRegistry>,
311    pub(crate) alias: String,
312    pub(crate) method: Option<Method>,
313    pub(crate) path: Option<String>,
314}
315
316impl MockSetupBuilder {
317    /// Match `GET <path>`.
318    #[must_use]
319    pub fn get(mut self, path: &str) -> Self {
320        self.method = Some(Method::GET);
321        self.path = Some(path.to_owned());
322        self
323    }
324    /// Match `POST <path>`.
325    #[must_use]
326    pub fn post(mut self, path: &str) -> Self {
327        self.method = Some(Method::POST);
328        self.path = Some(path.to_owned());
329        self
330    }
331    /// Match `PUT <path>`.
332    #[must_use]
333    pub fn put(mut self, path: &str) -> Self {
334        self.method = Some(Method::PUT);
335        self.path = Some(path.to_owned());
336        self
337    }
338    /// Match `PATCH <path>`.
339    #[must_use]
340    pub fn patch(mut self, path: &str) -> Self {
341        self.method = Some(Method::PATCH);
342        self.path = Some(path.to_owned());
343        self
344    }
345    /// Match `DELETE <path>`.
346    #[must_use]
347    pub fn delete(mut self, path: &str) -> Self {
348        self.method = Some(Method::DELETE);
349        self.path = Some(path.to_owned());
350        self
351    }
352
353    /// Register the mock entry and return a [`MockHandle`] for assertions.
354    ///
355    /// `status` is the HTTP status code to return.
356    /// `body` is serialised as JSON and returned as the response body.
357    #[must_use]
358    pub fn respond_with(self, status: u16, body: serde_json::Value) -> MockHandle {
359        let path = self.path.clone().unwrap_or_default();
360        let method_str = self
361            .method
362            .as_ref()
363            .map_or_else(|| "*".to_owned(), ToString::to_string);
364        let call_count = Arc::new(AtomicUsize::new(0));
365
366        self.registry.register(MockEntry {
367            method: self.method,
368            path: path.clone(),
369            alias: Some(self.alias.clone()),
370            status,
371            body: Some(body),
372            call_count: call_count.clone(),
373        });
374
375        MockHandle {
376            alias: self.alias,
377            method: method_str,
378            path,
379            call_count,
380        }
381    }
382
383    /// Convenience variant that returns the given status with an empty body.
384    ///
385    /// Unlike [`respond_with`](Self::respond_with), this stores `body: None` so
386    /// the mock response truly has zero body bytes (not the JSON literal `null`).
387    #[must_use]
388    pub fn respond_with_status(self, status: u16) -> MockHandle {
389        let path = self.path.clone().unwrap_or_default();
390        let method_str = self
391            .method
392            .as_ref()
393            .map_or_else(|| "*".to_owned(), ToString::to_string);
394        let call_count = Arc::new(AtomicUsize::new(0));
395
396        self.registry.register(MockEntry {
397            method: self.method,
398            path: path.clone(),
399            alias: Some(self.alias.clone()),
400            status,
401            body: None,
402            call_count: call_count.clone(),
403        });
404
405        MockHandle {
406            alias: self.alias,
407            method: method_str,
408            path,
409            call_count,
410        }
411    }
412}
413
414// ── Client ───────────────────────────────────────────────────────────────────
415
416/// Traced outbound HTTP client with automatic retries and test-mock support.
417///
418/// Extracted from `AppState` via Axum's extractor machinery — declare it as a
419/// handler parameter to get a pre-configured instance that respects
420/// `[http.client]` config and, in test builds, intercepts requests against any
421/// registered mocks.
422///
423/// ```rust,no_run
424/// use autumn_web::prelude::*;
425/// use autumn_web::http::Client;
426///
427/// #[get("/ping-upstream")]
428/// async fn ping(client: Client) -> AutumnResult<&'static str> {
429///     client.get("https://api.example.com/health").send().await?;
430///     Ok("ok")
431/// }
432/// ```
433///
434/// You can also construct a standalone client outside of a handler:
435///
436/// ```rust
437/// use autumn_web::http::Client;
438///
439/// let client = Client::new();
440/// ```
441#[derive(Clone)]
442pub struct Client {
443    inner: reqwest::Client,
444    /// Named alias — used to look up base URLs from config and to match mocks.
445    alias: Option<String>,
446    /// Base URL prepended to relative paths.
447    base_url: Option<String>,
448    /// Alias → base URL map loaded from `[http.client.base_urls]` config.
449    base_urls: HashMap<String, String>,
450    retry_policy: RetryPolicy,
451    /// When present (test builds), matching requests bypass the network.
452    mock: Option<Arc<MockRegistry>>,
453    /// Resilience configuration for circuit breakers.
454    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
455}
456
457impl Client {
458    /// Create a new client with default settings (30 s timeout, 3 retries on
459    /// idempotent methods).
460    #[must_use]
461    pub fn new() -> Self {
462        Self::with_timeout(Duration::from_secs(30))
463    }
464
465    /// Create a client with a custom per-request timeout.
466    ///
467    /// # Panics
468    ///
469    /// Panics if the underlying TLS backend cannot be initialised (should not
470    /// happen with the default `rustls-tls` feature).
471    #[must_use]
472    pub fn with_timeout(timeout: Duration) -> Self {
473        let inner = reqwest::ClientBuilder::new()
474            .timeout(timeout)
475            .build()
476            .expect("failed to build reqwest client");
477        Self {
478            inner,
479            alias: None,
480            base_url: None,
481            base_urls: HashMap::new(),
482            retry_policy: RetryPolicy {
483                max_retries: 3,
484                retry_idempotent_only: true,
485                max_retry_after: Duration::from_secs(10),
486                request_timeout: Some(timeout),
487            },
488            mock: None,
489            resilience_config: None,
490        }
491    }
492
493    /// Create a client from `[http.client]` framework configuration.
494    ///
495    /// # Panics
496    ///
497    /// Panics if the underlying TLS backend cannot be initialised (should not
498    /// happen with the default `rustls-tls` feature).
499    #[must_use]
500    pub fn from_config(config: &crate::config::HttpClientConfig) -> Self {
501        let timeout = Duration::from_secs(config.timeout_secs);
502        let inner = reqwest::ClientBuilder::new()
503            .timeout(timeout)
504            .build()
505            .expect("failed to build reqwest client");
506        Self {
507            inner,
508            alias: None,
509            base_url: None,
510            base_urls: config.base_urls.clone(),
511            retry_policy: RetryPolicy {
512                max_retries: config.max_retries,
513                retry_idempotent_only: true,
514                max_retry_after: Duration::from_secs(config.max_retry_after_secs),
515                request_timeout: Some(timeout),
516            },
517            mock: None,
518            resilience_config: None,
519        }
520    }
521
522    /// Attach a mock registry (used by the test harness).
523    pub(crate) fn with_mock(mut self, registry: Arc<MockRegistry>) -> Self {
524        self.mock = Some(registry);
525        self
526    }
527
528    /// Build a client from runtime application state.
529    pub(crate) fn from_state(state: &crate::AppState) -> Self {
530        let config = state.extension::<crate::config::HttpConfig>().or_else(|| {
531            state
532                .extension::<crate::config::AutumnConfig>()
533                .map(|c| Arc::new(c.http.clone()))
534        });
535        let mut client = config.map_or_else(Self::new, |cfg| Self::from_config(&cfg.client));
536
537        let resilience = state
538            .extension::<crate::config::AutumnConfig>()
539            .map(|c| Arc::new(c.resilience.clone()));
540        client.resilience_config = resilience;
541
542        if let Some(ext) = state.extension::<HttpMockRegistryExt>() {
543            client = client.with_mock(ext.0.clone());
544        }
545
546        client
547    }
548
549    /// Return a clone of this client scoped to the named alias.
550    ///
551    /// When a `[http.client.base_urls]` entry exists for the alias the client
552    /// will prepend that URL to all relative paths. Mocks registered for the
553    /// alias via [`TestApp::http_mock`](crate::test::TestApp::http_mock) will
554    /// match requests made through this named client.
555    #[must_use]
556    pub fn named(&self, alias: &str) -> Self {
557        let base_url = self
558            .base_urls
559            .get(alias)
560            .cloned()
561            .or_else(|| self.base_url.clone());
562        Self {
563            inner: self.inner.clone(),
564            alias: Some(alias.to_owned()),
565            base_url,
566            base_urls: self.base_urls.clone(),
567            retry_policy: self.retry_policy.clone(),
568            mock: self.mock.clone(),
569            resilience_config: self.resilience_config.clone(),
570        }
571    }
572
573    /// Set (or override) the base URL prepended to relative request paths.
574    #[must_use]
575    pub fn with_base_url(&self, base_url: impl Into<String>) -> Self {
576        Self {
577            inner: self.inner.clone(),
578            alias: self.alias.clone(),
579            base_url: Some(base_url.into()),
580            base_urls: self.base_urls.clone(),
581            retry_policy: self.retry_policy.clone(),
582            mock: self.mock.clone(),
583            resilience_config: self.resilience_config.clone(),
584        }
585    }
586
587    fn build_request(&self, method: Method, url: impl AsRef<str>) -> RequestBuilder {
588        let url_str = url.as_ref();
589        let full_url = if url_str.starts_with("http://") || url_str.starts_with("https://") {
590            url_str.to_owned()
591        } else if let Some(base) = &self.base_url {
592            format!(
593                "{}/{}",
594                base.trim_end_matches('/'),
595                url_str.trim_start_matches('/')
596            )
597        } else {
598            url_str.to_owned()
599        };
600
601        RequestBuilder {
602            client: self.inner.clone(),
603            method,
604            url: full_url,
605            extra_headers: HeaderMap::new(),
606            body: None,
607            retry_policy: self.retry_policy.clone(),
608            mock: self.mock.clone(),
609            alias: self.alias.clone(),
610            pending_error: None,
611            resilience_config: self.resilience_config.clone(),
612        }
613    }
614
615    /// Build a `GET` request.
616    #[must_use]
617    pub fn get(&self, url: impl AsRef<str>) -> RequestBuilder {
618        self.build_request(Method::GET, url)
619    }
620    /// Build a `POST` request.
621    #[must_use]
622    pub fn post(&self, url: impl AsRef<str>) -> RequestBuilder {
623        self.build_request(Method::POST, url)
624    }
625    /// Build a `PUT` request.
626    #[must_use]
627    pub fn put(&self, url: impl AsRef<str>) -> RequestBuilder {
628        self.build_request(Method::PUT, url)
629    }
630    /// Build a `PATCH` request.
631    #[must_use]
632    pub fn patch(&self, url: impl AsRef<str>) -> RequestBuilder {
633        self.build_request(Method::PATCH, url)
634    }
635    /// Build a `DELETE` request.
636    #[must_use]
637    pub fn delete(&self, url: impl AsRef<str>) -> RequestBuilder {
638        self.build_request(Method::DELETE, url)
639    }
640}
641
642impl Default for Client {
643    fn default() -> Self {
644        Self::new()
645    }
646}
647
648impl axum::extract::FromRequestParts<crate::AppState> for Client {
649    type Rejection = std::convert::Infallible;
650
651    async fn from_request_parts(
652        _parts: &mut http::request::Parts,
653        state: &crate::AppState,
654    ) -> Result<Self, std::convert::Infallible> {
655        Ok(Self::from_state(state))
656    }
657}
658
659// ── RequestBuilder ───────────────────────────────────────────────────────────
660
661/// Fluent outbound request builder produced by [`Client`] methods.
662pub struct RequestBuilder {
663    client: reqwest::Client,
664    method: Method,
665    url: String,
666    extra_headers: HeaderMap,
667    /// Request body. `Bytes` gives O(1) clones across retry attempts.
668    body: Option<Bytes>,
669    retry_policy: RetryPolicy,
670    mock: Option<Arc<MockRegistry>>,
671    alias: Option<String>,
672    /// Captures errors from `json()` or invalid headers to surface in `send()`.
673    pending_error: Option<ClientError>,
674    /// Resilience configuration for circuit breakers.
675    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
676}
677
678impl RequestBuilder {
679    /// Append a request header.
680    ///
681    /// Headers named `authorization`, `cookie`, or `set-cookie` are accepted
682    /// normally but are **redacted** in tracing events and log output.
683    /// Invalid header names or values emit a `tracing::warn!` and are skipped.
684    #[must_use]
685    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
686        let name_str = name.as_ref();
687        let value_str = value.as_ref();
688        match (
689            HeaderName::from_bytes(name_str.as_bytes()),
690            HeaderValue::from_str(value_str),
691        ) {
692            (Ok(n), Ok(v)) => {
693                self.extra_headers.insert(n, v);
694            }
695            (Err(e), _) => {
696                tracing::warn!(header.name = name_str, error = %e, "invalid header name — header skipped");
697            }
698            (_, Err(e)) => {
699                tracing::warn!(header.name = name_str, error = %e, "invalid header value — header skipped");
700            }
701        }
702        self
703    }
704
705    /// Serialise `body` as JSON and set `Content-Type: application/json`.
706    ///
707    /// Serialisation errors are captured and returned when [`send`](Self::send)
708    /// is called rather than being silently discarded.
709    #[must_use]
710    pub fn json<T: Serialize>(mut self, body: &T) -> Self {
711        match serde_json::to_vec(body) {
712            Ok(bytes) => {
713                self.body = Some(Bytes::from(bytes));
714                self = self.header("content-type", "application/json");
715            }
716            Err(e) => {
717                self.pending_error = Some(ClientError::Json(e));
718            }
719        }
720        self
721    }
722
723    /// Set a plain-text body.
724    #[must_use]
725    pub fn text_body(mut self, body: impl Into<String>) -> Self {
726        self.body = Some(Bytes::from(body.into().into_bytes()));
727        self
728    }
729
730    /// Override the maximum retry count for this request.
731    ///
732    /// Also clears the idempotent-only flag so non-idempotent methods such as
733    /// `POST` and `PATCH` are retried when the caller explicitly requests it.
734    #[must_use]
735    pub const fn retries(mut self, max: u32) -> Self {
736        self.retry_policy.max_retries = max;
737        self.retry_policy.retry_idempotent_only = false;
738        self
739    }
740
741    /// Override the maximum `Retry-After` sleep duration for this request.
742    #[must_use]
743    pub const fn max_retry_after(mut self, max: Duration) -> Self {
744        self.retry_policy.max_retry_after = max;
745        self
746    }
747
748    /// Disable retries for this request.
749    #[must_use]
750    pub const fn no_retry(mut self) -> Self {
751        self.retry_policy.max_retries = 0;
752        self
753    }
754
755    /// Send the request, applying retries and returning a [`Response`].
756    ///
757    /// # Errors
758    ///
759    /// Returns [`ClientError::Json`] if a prior `.json()` call failed to
760    /// serialise the body.  Returns [`ClientError::Request`] for transport
761    /// errors that exhaust all retry attempts.  Returns [`ClientError::NoMock`]
762    /// if the request is made in a test context without a matching mock entry.
763    ///
764    /// # Panics
765    ///
766    /// Contains an internal `unreachable!()` that guards against a logic error
767    /// in the retry loop; it cannot be reached in practice.
768    pub async fn send(self) -> Result<Response, ClientError> {
769        // Surface any error captured during builder construction.
770        if let Some(err) = self.pending_error {
771            return Err(err);
772        }
773
774        // Bypassing circuit breaker if a mock registry is present.
775        if self.mock.is_some() {
776            return self.send_inner(false).await;
777        }
778
779        // ── Resilience / Circuit Breaker ──────────────────────────────────
780        let host = url::Url::parse(&self.url).ok().map_or_else(
781            || "unknown".to_owned(),
782            |u| {
783                let h = u.host_str().unwrap_or("unknown");
784                u.port()
785                    .map_or_else(|| h.to_owned(), |port| format!("{h}:{port}"))
786            },
787        );
788
789        let breaker = self.resilience_config.as_ref().map_or_else(
790            || {
791                crate::circuit_breaker::global_registry().get_or_create(
792                    &host,
793                    crate::circuit_breaker::CircuitBreakerPolicy::default(),
794                )
795            },
796            |rc| {
797                let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(rc, &host);
798                crate::circuit_breaker::global_registry().get_or_create_with_config(&host, policy)
799            },
800        );
801
802        // Check if circuit breaker is open
803        if breaker.before_call().is_err() {
804            return Err(ClientError::CircuitBreakerOpen);
805        }
806        let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
807
808        let is_half_open = breaker.state() == crate::circuit_breaker::CircuitState::HalfOpen;
809        let res = self.send_inner(is_half_open).await;
810        match &res {
811            Ok(resp) => {
812                let success = resp.status().as_u16() < 500;
813                if success {
814                    guard.success();
815                } else {
816                    guard.failure();
817                }
818            }
819            Err(_) => {
820                guard.failure();
821            }
822        }
823        res
824    }
825
826    async fn send_inner(self, suppress_retries: bool) -> Result<Response, ClientError> {
827        // ── Mock short-circuit ──────────────────────────────────────────────
828        if let Some(ref mock) = self.mock {
829            match mock.find_match(&self.method, &self.url, self.alias.as_deref()) {
830                Some(mock_resp) => {
831                    let status = reqwest::StatusCode::from_u16(mock_resp.status)
832                        .unwrap_or(reqwest::StatusCode::OK);
833                    let body_bytes = mock_resp
834                        .body
835                        .as_ref()
836                        .map(|v| serde_json::to_vec(v).unwrap_or_default())
837                        .unwrap_or_default();
838
839                    tracing::info!(
840                        http.method = %self.method,
841                        http.url = %self.url,
842                        http.status = mock_resp.status,
843                        "[mock] outbound request intercepted"
844                    );
845
846                    return Ok(Response {
847                        status,
848                        headers: HeaderMap::new(),
849                        body: Bytes::from(body_bytes),
850                        url: None,
851                    });
852                }
853                None => {
854                    // A mock registry is present but nothing matched — treat as
855                    // a test failure rather than falling through to the network.
856                    return Err(ClientError::NoMock(
857                        self.method.to_string(),
858                        self.url.clone(),
859                    ));
860                }
861            }
862        }
863
864        // ── Real network request with retries ───────────────────────────────
865        let start = Instant::now();
866        let max_attempts = if suppress_retries {
867            1
868        } else if is_idempotent_method(&self.method) || !self.retry_policy.retry_idempotent_only {
869            self.retry_policy.max_retries.saturating_add(1)
870        } else {
871            1
872        };
873
874        for attempt in 0..max_attempts {
875            if attempt > 0 {
876                // Cap the exponent to prevent u64 overflow when max_retries is large.
877                let exp = (attempt - 1).min(10);
878                let delay = Duration::from_millis(100 * (1_u64 << exp));
879                tokio::time::sleep(delay).await;
880            }
881
882            let mut req = self.client.request(self.method.clone(), &self.url);
883
884            // Inject W3C trace context headers from the active span.
885            req = inject_trace_context(req);
886
887            // Apply caller-supplied headers (may override or extend trace headers).
888            for (name, value) in &self.extra_headers {
889                req = req.header(name.clone(), value.clone());
890            }
891
892            if let Some(body) = &self.body {
893                req = req.body(body.clone());
894            }
895
896            match req.send().await {
897                Ok(resp) => {
898                    let status = resp.status();
899                    let headers = resp.headers().clone();
900                    let url_used = resp.url().clone();
901
902                    // 429 → honour Retry-After and retry if attempts remain.
903                    if status.as_u16() == 429 && attempt + 1 < max_attempts {
904                        let mut sleep_delay =
905                            parse_retry_after(&headers).unwrap_or(Duration::from_secs(1));
906                        sleep_delay = sleep_delay.min(self.retry_policy.max_retry_after);
907                        if let Some(req_timeout) = self.retry_policy.request_timeout {
908                            sleep_delay = sleep_delay.min(req_timeout);
909                        }
910                        tokio::time::sleep(sleep_delay).await;
911                        continue;
912                    }
913
914                    // 5xx transient gateway errors → retry if attempts remain.
915                    if is_retryable_status(status.as_u16()) && attempt + 1 < max_attempts {
916                        continue;
917                    }
918
919                    let body = resp
920                        .bytes()
921                        .await
922                        .map_err(|e| ClientError::Request(e.without_url()))?;
923                    let elapsed = start.elapsed();
924                    log_request(
925                        self.method.as_str(),
926                        &url_used,
927                        status.as_u16(),
928                        elapsed,
929                        &self.extra_headers,
930                    );
931
932                    return Ok(Response {
933                        status,
934                        headers,
935                        body,
936                        url: Some(url_used),
937                    });
938                }
939                // Only retry transient connect/timeout errors; non-transient errors
940                // (e.g. malformed URL) fail immediately.
941                Err(e) if (e.is_connect() || e.is_timeout()) && attempt + 1 < max_attempts => {}
942                Err(e) => return Err(ClientError::Request(e.without_url())),
943            }
944        }
945
946        // The retry loop always returns inside the last attempt; this is unreachable.
947        unreachable!("retry loop exited without returning a result — this is a bug")
948    }
949}
950
951// ── Internal helpers ─────────────────────────────────────────────────────────
952
953const fn is_idempotent_method(method: &Method) -> bool {
954    matches!(
955        *method,
956        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS | Method::TRACE
957    )
958}
959
960const fn is_retryable_status(status: u16) -> bool {
961    matches!(status, 502..=504)
962}
963
964fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
965    let value = headers.get("retry-after")?.to_str().ok()?;
966    // Integer seconds (most common form).
967    if let Ok(secs) = value.parse::<u64>() {
968        return Some(Duration::from_secs(secs));
969    }
970    // HTTP-date format per RFC 9110 (e.g. "Tue, 01 Jan 2030 00:00:00 GMT").
971    let dt = chrono::DateTime::parse_from_rfc2822(value).ok()?;
972    let now = chrono::Utc::now();
973    let future = dt.with_timezone(&chrono::Utc);
974    let secs = u64::try_from((future - now).num_seconds().max(0)).unwrap_or(0);
975    Some(Duration::from_secs(secs))
976}
977
978const REDACTED_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie"];
979
980fn is_sensitive_header(name: &str) -> bool {
981    REDACTED_HEADERS
982        .iter()
983        .any(|h| h.eq_ignore_ascii_case(name))
984}
985
986fn log_request(
987    method: &str,
988    url: &reqwest::Url,
989    status: u16,
990    elapsed: Duration,
991    headers: &HeaderMap,
992) {
993    let host = url.host_str().unwrap_or("unknown");
994    let path = url.path();
995
996    // Collect non-sensitive header names for the span (values are omitted).
997    let sent_headers: Vec<&str> = headers
998        .keys()
999        .map(HeaderName::as_str)
1000        .filter(|k| !is_sensitive_header(k))
1001        .collect();
1002
1003    tracing::info!(
1004        http.method = method,
1005        http.host = host,
1006        http.path = path,
1007        http.status = status,
1008        http.elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
1009        http.sent_headers = ?sent_headers,
1010        "outbound request"
1011    );
1012}
1013
1014/// Inject the active span's W3C `traceparent` / `tracestate` headers into the
1015/// request builder.  No-ops when the `telemetry-otlp` feature is disabled or
1016/// when there is no active span with a valid context.
1017#[allow(clippy::missing_const_for_fn)]
1018fn inject_trace_context(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1019    #[cfg(not(feature = "telemetry-otlp"))]
1020    {
1021        builder
1022    }
1023    #[cfg(feature = "telemetry-otlp")]
1024    {
1025        use std::collections::HashMap;
1026        use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1027        let cx = tracing::Span::current().context();
1028        let mut map = HashMap::<String, String>::new();
1029        opentelemetry::global::get_text_map_propagator(|propagator| {
1030            propagator.inject_context(&cx, &mut TraceHeaderInjector(&mut map));
1031        });
1032        let mut builder = builder;
1033        for (k, v) in map {
1034            if let Ok(value) = HeaderValue::from_str(&v) {
1035                builder = builder.header(k, value);
1036            }
1037        }
1038        builder
1039    }
1040}
1041
1042#[cfg(feature = "telemetry-otlp")]
1043struct TraceHeaderInjector<'a>(&'a mut std::collections::HashMap<String, String>);
1044
1045#[cfg(feature = "telemetry-otlp")]
1046impl opentelemetry::propagation::Injector for TraceHeaderInjector<'_> {
1047    fn set(&mut self, key: &str, value: String) {
1048        self.0.insert(key.to_owned(), value);
1049    }
1050}
1051
1052// ── Tests ────────────────────────────────────────────────────────────────────
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use crate::config::HttpClientConfig;
1058
1059    // RED-PHASE TEST 1: Client can be constructed with defaults.
1060    #[test]
1061    fn client_constructs_with_defaults() {
1062        let client = Client::new();
1063        assert!(client.alias.is_none());
1064        assert!(client.base_url.is_none());
1065        assert_eq!(client.retry_policy.max_retries, 3);
1066    }
1067
1068    // RED-PHASE TEST 2: Fluent RequestBuilder API compiles.
1069    #[test]
1070    fn request_builder_fluent_api_compiles() {
1071        let client = Client::new();
1072        let _builder = client
1073            .post("https://example.com/api")
1074            .header("x-api-key", "secret")
1075            .json(&serde_json::json!({"key": "value"}))
1076            .retries(2);
1077    }
1078
1079    // RED-PHASE TEST 3: Response accessors work.
1080    #[test]
1081    fn response_accessors_work() {
1082        let payload = serde_json::json!({"id": 42, "name": "Alice"});
1083        let body = serde_json::to_vec(&payload).unwrap();
1084        let resp = Response {
1085            status: reqwest::StatusCode::OK,
1086            headers: HeaderMap::new(),
1087            body: Bytes::from(body),
1088            url: None,
1089        };
1090        assert_eq!(resp.status().as_u16(), 200);
1091        assert!(resp.is_success());
1092    }
1093
1094    // RED-PHASE TEST 4: Response::json() deserialises correctly.
1095    #[test]
1096    fn response_json_deserialises() {
1097        #[derive(serde::Deserialize, PartialEq, Debug)]
1098        struct User {
1099            id: i32,
1100            name: String,
1101        }
1102        let payload = serde_json::json!({"id": 1, "name": "Bob"});
1103        let resp = Response {
1104            status: reqwest::StatusCode::OK,
1105            headers: HeaderMap::new(),
1106            body: Bytes::from(serde_json::to_vec(&payload).unwrap()),
1107            url: None,
1108        };
1109        let user: User = resp.json().unwrap();
1110        assert_eq!(user.id, 1);
1111        assert_eq!(user.name, "Bob");
1112    }
1113
1114    // RED-PHASE TEST 5: Response::text() returns UTF-8 string.
1115    #[test]
1116    fn response_text_returns_string() {
1117        let resp = Response {
1118            status: reqwest::StatusCode::OK,
1119            headers: HeaderMap::new(),
1120            body: Bytes::from_static(b"hello world"),
1121            url: None,
1122        };
1123        assert_eq!(resp.text(), "hello world");
1124    }
1125
1126    // RED-PHASE TEST 6: Response::bytes() returns raw bytes.
1127    #[test]
1128    fn response_bytes_returns_raw() {
1129        let resp = Response {
1130            status: reqwest::StatusCode::CREATED,
1131            headers: HeaderMap::new(),
1132            body: Bytes::from_static(b"\x00\x01\x02"),
1133            url: None,
1134        };
1135        assert_eq!(resp.bytes(), Bytes::from_static(b"\x00\x01\x02"));
1136    }
1137
1138    // RED-PHASE TEST 7: HttpClientConfig deserialises from [http.client] TOML.
1139    #[test]
1140    fn config_deserialises_from_toml() {
1141        // Simulate the [http.client] section as it appears in autumn.toml.
1142        let toml = r#"
1143            [client]
1144            timeout_secs = 60
1145            max_retries = 5
1146            [client.base_urls]
1147            stripe = "https://api.stripe.com"
1148            sendgrid = "https://api.sendgrid.com"
1149        "#;
1150        let http_cfg: crate::config::HttpConfig = toml::from_str(toml).unwrap();
1151        let config = &http_cfg.client;
1152        assert_eq!(config.timeout_secs, 60);
1153        assert_eq!(config.max_retries, 5);
1154        assert_eq!(
1155            config.base_urls.get("stripe").map(String::as_str),
1156            Some("https://api.stripe.com")
1157        );
1158        assert_eq!(
1159            config.base_urls.get("sendgrid").map(String::as_str),
1160            Some("https://api.sendgrid.com")
1161        );
1162    }
1163
1164    // RED-PHASE TEST 8: HttpClientConfig has correct defaults.
1165    #[test]
1166    fn config_has_correct_defaults() {
1167        let config = HttpClientConfig::default();
1168        assert_eq!(config.timeout_secs, 30);
1169        assert_eq!(config.max_retries, 3);
1170        assert!(config.base_urls.is_empty());
1171    }
1172
1173    // RED-PHASE TEST 9: is_idempotent_method returns correct values.
1174    #[test]
1175    fn idempotent_method_classification() {
1176        assert!(is_idempotent_method(&Method::GET));
1177        assert!(is_idempotent_method(&Method::HEAD));
1178        assert!(is_idempotent_method(&Method::PUT));
1179        assert!(is_idempotent_method(&Method::DELETE));
1180        assert!(is_idempotent_method(&Method::OPTIONS));
1181        assert!(is_idempotent_method(&Method::TRACE));
1182        assert!(!is_idempotent_method(&Method::POST));
1183        assert!(!is_idempotent_method(&Method::PATCH));
1184    }
1185
1186    // RED-PHASE TEST 10: is_retryable_status returns correct values.
1187    #[test]
1188    fn retryable_status_classification() {
1189        assert!(is_retryable_status(502));
1190        assert!(is_retryable_status(503));
1191        assert!(is_retryable_status(504));
1192        assert!(!is_retryable_status(200));
1193        assert!(!is_retryable_status(400));
1194        assert!(!is_retryable_status(404));
1195        assert!(!is_retryable_status(500));
1196        assert!(!is_retryable_status(429));
1197    }
1198
1199    // RED-PHASE TEST 11: parse_retry_after parses seconds correctly.
1200    #[test]
1201    fn retry_after_header_parsing() {
1202        let mut headers = HeaderMap::new();
1203        headers.insert(
1204            reqwest::header::HeaderName::from_static("retry-after"),
1205            HeaderValue::from_static("5"),
1206        );
1207        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(5)));
1208
1209        let empty = HeaderMap::new();
1210        assert_eq!(parse_retry_after(&empty), None);
1211    }
1212
1213    // RED-PHASE TEST 12: Sensitive header detection.
1214    #[test]
1215    fn sensitive_header_detection() {
1216        assert!(is_sensitive_header("authorization"));
1217        assert!(is_sensitive_header("Authorization"));
1218        assert!(is_sensitive_header("AUTHORIZATION"));
1219        assert!(is_sensitive_header("cookie"));
1220        assert!(is_sensitive_header("set-cookie"));
1221        assert!(!is_sensitive_header("content-type"));
1222        assert!(!is_sensitive_header("x-api-key"));
1223    }
1224
1225    // RED-PHASE TEST 13: MockRegistry captures and matches calls.
1226    #[tokio::test]
1227    async fn mock_registry_captures_calls() {
1228        let registry = Arc::new(MockRegistry::new());
1229        let call_count = Arc::new(AtomicUsize::new(0));
1230
1231        registry.register(MockEntry {
1232            method: Some(Method::POST),
1233            path: "/charges".to_owned(),
1234            alias: Some("stripe".to_owned()),
1235            status: 200,
1236            body: Some(serde_json::json!({"id": "ch_123"})),
1237            call_count: call_count.clone(),
1238        });
1239
1240        let client = Client::new().with_mock(registry).named("stripe");
1241
1242        let resp = client
1243            .post("https://api.stripe.com/charges")
1244            .json(&serde_json::json!({"amount": 1000}))
1245            .send()
1246            .await
1247            .unwrap();
1248
1249        assert_eq!(resp.status().as_u16(), 200);
1250        let body: serde_json::Value = resp.json().unwrap();
1251        assert_eq!(body["id"], "ch_123");
1252        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1253    }
1254
1255    // RED-PHASE TEST 14: MockHandle::expect_called passes when count matches.
1256    #[tokio::test]
1257    async fn mock_handle_expect_called_passes() {
1258        let registry = Arc::new(MockRegistry::new());
1259        let call_count = Arc::new(AtomicUsize::new(0));
1260
1261        registry.register(MockEntry {
1262            method: Some(Method::GET),
1263            path: "/users/1".to_owned(),
1264            alias: None,
1265            status: 200,
1266            body: Some(serde_json::json!({"name": "Alice"})),
1267            call_count: call_count.clone(),
1268        });
1269
1270        let handle = MockHandle {
1271            alias: "test".to_owned(),
1272            method: "GET".to_owned(),
1273            path: "/users/1".to_owned(),
1274            call_count: call_count.clone(),
1275        };
1276
1277        let client = Client::new().with_mock(registry);
1278        client
1279            .get("https://api.example.com/users/1")
1280            .send()
1281            .await
1282            .unwrap();
1283
1284        handle.expect_called(1);
1285        assert_eq!(handle.call_count(), 1);
1286    }
1287
1288    // RED-PHASE TEST 15: MockRegistry matches by URL path suffix.
1289    #[tokio::test]
1290    async fn mock_matches_by_path_suffix() {
1291        let registry = Arc::new(MockRegistry::new());
1292        let call_count = Arc::new(AtomicUsize::new(0));
1293
1294        registry.register(MockEntry {
1295            method: Some(Method::POST),
1296            path: "/v1/charges".to_owned(),
1297            alias: None,
1298            status: 201,
1299            body: Some(serde_json::json!({"created": true})),
1300            call_count: call_count.clone(),
1301        });
1302
1303        let client = Client::new().with_mock(registry);
1304        let resp = client
1305            .post("https://api.stripe.com/v1/charges")
1306            .send()
1307            .await
1308            .unwrap();
1309
1310        assert_eq!(resp.status().as_u16(), 201);
1311        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1312    }
1313
1314    // RED-PHASE TEST 16: NoMock error when mock registry has no match.
1315    #[tokio::test]
1316    async fn no_mock_error_when_unmatched() {
1317        let registry = Arc::new(MockRegistry::new());
1318        let client = Client::new().with_mock(registry);
1319        let result = client.post("https://api.example.com/unknown").send().await;
1320        assert!(matches!(result, Err(ClientError::NoMock(_, _))));
1321    }
1322
1323    // RED-PHASE TEST 17: MockSetupBuilder registers and returns MockHandle.
1324    #[tokio::test]
1325    async fn mock_setup_builder_registers_entry() {
1326        let registry = Arc::new(MockRegistry::new());
1327        let builder = MockSetupBuilder {
1328            registry: registry.clone(),
1329            alias: "myservice".to_owned(),
1330            method: None,
1331            path: None,
1332        };
1333
1334        let handle = builder
1335            .post("/api/resource")
1336            .respond_with(201, serde_json::json!({"ok": true}));
1337
1338        let client = Client::new().with_mock(registry).named("myservice");
1339        client
1340            .post("https://myservice.example.com/api/resource")
1341            .send()
1342            .await
1343            .unwrap();
1344
1345        handle.expect_called(1);
1346    }
1347
1348    // RED-PHASE TEST 18: Client::from_config respects timeout and retries.
1349    #[test]
1350    fn client_from_config() {
1351        let config = HttpClientConfig {
1352            timeout_secs: 10,
1353            max_retries: 1,
1354            max_retry_after_secs: 10,
1355            base_urls: std::collections::HashMap::new(),
1356        };
1357        let client = Client::from_config(&config);
1358        assert_eq!(client.retry_policy.max_retries, 1);
1359    }
1360
1361    // RED-PHASE TEST 19: Client.named() preserves mock registry.
1362    #[test]
1363    fn named_client_preserves_mock_registry() {
1364        let registry = Arc::new(MockRegistry::new());
1365        let client = Client::new().with_mock(registry);
1366        let named = client.named("stripe");
1367        assert!(named.mock.is_some());
1368        assert_eq!(named.alias.as_deref(), Some("stripe"));
1369    }
1370
1371    // RED-PHASE TEST 20: base_url is prepended to relative paths.
1372    #[test]
1373    fn base_url_prepended_to_relative_path() {
1374        let client = Client::new();
1375        let client = client.with_base_url("https://api.stripe.com");
1376        let builder = client.post("/v1/charges");
1377        assert_eq!(builder.url, "https://api.stripe.com/v1/charges");
1378    }
1379
1380    // RED-PHASE TEST 21: Absolute URLs bypass base_url.
1381    #[test]
1382    fn absolute_url_bypasses_base_url() {
1383        let client = Client::new().with_base_url("https://ignored.example.com");
1384        let builder = client.get("https://actual.example.com/path");
1385        assert_eq!(builder.url, "https://actual.example.com/path");
1386    }
1387
1388    // RED-PHASE TEST 22: RetryPolicy can be overridden per-request.
1389    #[test]
1390    fn retry_override_per_request() {
1391        let client = Client::new(); // default: 3 retries
1392        let builder = client.get("https://example.com").retries(0);
1393        assert_eq!(builder.retry_policy.max_retries, 0);
1394
1395        let no_retry = client.get("https://example.com").no_retry();
1396        assert_eq!(no_retry.retry_policy.max_retries, 0);
1397    }
1398
1399    // RED-PHASE TEST 23: Client extracts from AppState.
1400    #[tokio::test]
1401    async fn client_extracts_from_state() {
1402        use axum::extract::FromRequestParts;
1403        let state = crate::AppState::for_test();
1404        let mut parts = axum::http::Request::new(axum::body::Body::empty())
1405            .into_parts()
1406            .0;
1407        let client = Client::from_request_parts(&mut parts, &state)
1408            .await
1409            .unwrap();
1410        // Default client: no mock, no alias
1411        assert!(client.mock.is_none());
1412        assert!(client.alias.is_none());
1413    }
1414
1415    // RED-PHASE TEST 24: MockRegistryExt round-trips through AppState extensions.
1416    #[test]
1417    fn mock_registry_ext_round_trips_through_state() {
1418        let registry = Arc::new(MockRegistry::new());
1419        let ext = HttpMockRegistryExt(registry);
1420        let state = crate::AppState::for_test();
1421        state.insert_extension(ext);
1422        let retrieved = state.extension::<HttpMockRegistryExt>();
1423        assert!(retrieved.is_some());
1424    }
1425
1426    // TEST 25: named() resolves base URL from base_urls map in config.
1427    #[test]
1428    fn named_client_resolves_base_url_from_config() {
1429        let mut base_urls = std::collections::HashMap::new();
1430        base_urls.insert("stripe".to_owned(), "https://api.stripe.com".to_owned());
1431        let config = HttpClientConfig {
1432            timeout_secs: 30,
1433            max_retries: 3,
1434            max_retry_after_secs: 10,
1435            base_urls,
1436        };
1437        let client = Client::from_config(&config);
1438        let stripe = client.named("stripe");
1439        assert_eq!(stripe.base_url.as_deref(), Some("https://api.stripe.com"));
1440        assert_eq!(stripe.alias.as_deref(), Some("stripe"));
1441
1442        // Unknown alias falls back to client-level base_url (None in this case).
1443        let other = client.named("sendgrid");
1444        assert!(other.base_url.is_none());
1445    }
1446
1447    // TEST 26: from_request_parts uses AutumnConfig.http when no HttpConfig extension.
1448    #[tokio::test]
1449    async fn client_extracts_from_autumn_config_in_state() {
1450        use axum::extract::FromRequestParts;
1451        let mut cfg = crate::config::AutumnConfig::default();
1452        cfg.http.client.max_retries = 7;
1453        let state = crate::AppState::for_test();
1454        state.insert_extension(cfg);
1455
1456        let mut parts = axum::http::Request::new(axum::body::Body::empty())
1457            .into_parts()
1458            .0;
1459        let client = Client::from_request_parts(&mut parts, &state)
1460            .await
1461            .unwrap();
1462        assert_eq!(client.retry_policy.max_retries, 7);
1463    }
1464
1465    // TEST 27: respond_with_status produces a truly empty body (not JSON null).
1466    #[tokio::test]
1467    async fn respond_with_status_produces_empty_body() {
1468        let registry = Arc::new(MockRegistry::new());
1469        let builder = MockSetupBuilder {
1470            registry: registry.clone(),
1471            alias: "svc".to_owned(),
1472            method: None,
1473            path: None,
1474        };
1475        let _handle = builder.delete("/items/1").respond_with_status(204);
1476
1477        let client = Client::new().with_mock(registry).named("svc");
1478        let resp = client
1479            .delete("https://svc.example.com/items/1")
1480            .send()
1481            .await
1482            .unwrap();
1483
1484        assert_eq!(resp.status().as_u16(), 204);
1485        assert_eq!(
1486            resp.bytes(),
1487            bytes::Bytes::new(),
1488            "body must be empty, not \"null\""
1489        );
1490    }
1491
1492    // TEST 28: parse_retry_after handles HTTP-date format.
1493    #[test]
1494    fn retry_after_http_date_parsing() {
1495        let mut headers = HeaderMap::new();
1496        // A date far in the future to ensure the computed seconds > 0.
1497        headers.insert(
1498            reqwest::header::HeaderName::from_static("retry-after"),
1499            HeaderValue::from_static("Tue, 01 Jan 2030 00:00:00 GMT"),
1500        );
1501        let duration = parse_retry_after(&headers);
1502        assert!(duration.is_some(), "should parse HTTP-date Retry-After");
1503        assert!(
1504            duration.unwrap().as_secs() > 0,
1505            "future date should yield positive delay"
1506        );
1507    }
1508
1509    // TEST 29: non-idempotent POST with retries disabled makes only one attempt.
1510    #[tokio::test]
1511    async fn non_idempotent_post_no_retry() {
1512        let registry = Arc::new(MockRegistry::new());
1513        let call_count = Arc::new(AtomicUsize::new(0));
1514        registry.register(MockEntry {
1515            method: Some(Method::POST),
1516            path: "/endpoint".to_owned(),
1517            alias: None,
1518            status: 503,
1519            body: None,
1520            call_count: call_count.clone(),
1521        });
1522
1523        // With retry_idempotent_only=true (default), POST should NOT retry.
1524        let client = Client::new().with_mock(registry);
1525        let resp = client
1526            .post("https://example.com/endpoint")
1527            .send()
1528            .await
1529            .unwrap();
1530
1531        assert_eq!(resp.status().as_u16(), 503);
1532        // Mock was called exactly once — no retry for non-idempotent method.
1533        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1534    }
1535
1536    // TEST 30: find_match strips query string from relative URLs before comparing.
1537    #[tokio::test]
1538    async fn mock_strips_query_from_url_before_matching() {
1539        let registry = Arc::new(MockRegistry::new());
1540        let call_count = Arc::new(AtomicUsize::new(0));
1541        registry.register(MockEntry {
1542            method: Some(Method::GET),
1543            path: "/v1/charges".to_owned(),
1544            alias: None,
1545            status: 200,
1546            body: Some(serde_json::json!({"ok": true})),
1547            call_count: call_count.clone(),
1548        });
1549
1550        // The URL has a query string; the mock is registered without one.
1551        let client = Client::new().with_mock(registry);
1552        let resp = client
1553            .get("https://api.stripe.com/v1/charges?expand[]=balance_transaction")
1554            .send()
1555            .await
1556            .unwrap();
1557
1558        assert_eq!(resp.status().as_u16(), 200);
1559        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1560    }
1561
1562    // TEST 31: suffix match works when mock path starts with '/' and URL has a prefix.
1563    #[tokio::test]
1564    async fn mock_suffix_match_with_leading_slash_path() {
1565        let registry = Arc::new(MockRegistry::new());
1566        let call_count = Arc::new(AtomicUsize::new(0));
1567        // Register only the leaf segment (with leading slash).
1568        registry.register(MockEntry {
1569            method: Some(Method::POST),
1570            path: "/charges".to_owned(),
1571            alias: None,
1572            status: 201,
1573            body: Some(serde_json::json!({"matched": true})),
1574            call_count: call_count.clone(),
1575        });
1576
1577        let client = Client::new().with_mock(registry);
1578        // Full URL path is /v1/charges; mock path is /charges.
1579        let resp = client
1580            .post("https://api.stripe.com/v1/charges")
1581            .send()
1582            .await
1583            .unwrap();
1584
1585        assert_eq!(resp.status().as_u16(), 201);
1586        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1587    }
1588
1589    // TEST 32: retries() clears retry_idempotent_only so POST actually retries.
1590    #[test]
1591    fn retries_clears_idempotent_only_flag() {
1592        let client = Client::new();
1593        let builder = client.post("https://example.com").retries(2);
1594        assert_eq!(builder.retry_policy.max_retries, 2);
1595        assert!(
1596            !builder.retry_policy.retry_idempotent_only,
1597            "explicit retries() call must allow non-idempotent methods to retry"
1598        );
1599    }
1600
1601    // TEST 33: log_request covers the sensitive-header redaction path.
1602    #[test]
1603    fn log_request_completes_with_sensitive_headers() {
1604        let url = reqwest::Url::parse("https://api.example.com/v1/resource?q=1").unwrap();
1605        let mut headers = HeaderMap::new();
1606        headers.insert(
1607            HeaderName::from_static("content-type"),
1608            HeaderValue::from_static("application/json"),
1609        );
1610        headers.insert(
1611            HeaderName::from_static("authorization"),
1612            HeaderValue::from_static("Bearer sk_test_xxx"),
1613        );
1614        // Should complete without panicking; authorization is redacted from span.
1615        log_request("POST", &url, 201, Duration::from_millis(12), &headers);
1616    }
1617
1618    // TEST 34: inject_trace_context passthrough (without telemetry-otlp feature).
1619    #[test]
1620    fn inject_trace_context_passthrough_without_telemetry() {
1621        let inner = reqwest::Client::new();
1622        let builder = inner.get("https://example.com");
1623        // Without telemetry-otlp the function is a no-op; verify it doesn't panic.
1624        let _b = inject_trace_context(builder);
1625    }
1626
1627    // TEST 35: Real GET request exercises inject_trace_context, log_request, and
1628    // the success branch of the retry loop.
1629    #[tokio::test]
1630    #[allow(clippy::await_holding_lock)]
1631    async fn real_get_request_covers_network_path() {
1632        use axum::{Router, routing::get};
1633
1634        let _lock = crate::circuit_breaker::TEST_LOCK
1635            .lock()
1636            .unwrap_or_else(std::sync::PoisonError::into_inner);
1637        crate::circuit_breaker::global_registry().clear();
1638
1639        let app = Router::new().route("/ping", get(|| async { "pong" }));
1640        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1641        let addr = listener.local_addr().unwrap();
1642        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1643
1644        let client = Client::new();
1645        let resp = client
1646            .get(format!("http://127.0.0.1:{}/ping", addr.port()))
1647            .header("x-request-id", "test-35")
1648            .send()
1649            .await
1650            .unwrap();
1651
1652        assert_eq!(resp.status().as_u16(), 200);
1653        assert!(resp.url().is_some());
1654        assert_eq!(resp.text(), "pong");
1655
1656        crate::circuit_breaker::global_registry().clear();
1657    }
1658
1659    // TEST 36: Real POST with JSON body covers the body-sending code path.
1660    #[tokio::test]
1661    #[allow(clippy::await_holding_lock)]
1662    async fn real_post_with_json_body_covers_body_path() {
1663        use axum::{Json, Router, routing::post};
1664        use serde_json::Value;
1665
1666        let _lock = crate::circuit_breaker::TEST_LOCK
1667            .lock()
1668            .unwrap_or_else(std::sync::PoisonError::into_inner);
1669        crate::circuit_breaker::global_registry().clear();
1670
1671        let app = Router::new().route(
1672            "/echo",
1673            post(|Json(body): Json<Value>| async move { Json(body) }),
1674        );
1675        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1676        let addr = listener.local_addr().unwrap();
1677        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1678
1679        let client = Client::new();
1680        let resp = client
1681            .post(format!("http://127.0.0.1:{}/echo", addr.port()))
1682            .json(&serde_json::json!({"hello": "world"}))
1683            .send()
1684            .await
1685            .unwrap();
1686
1687        assert_eq!(resp.status().as_u16(), 200);
1688        let body: Value = resp.json().unwrap();
1689        assert_eq!(body["hello"], "world");
1690
1691        crate::circuit_breaker::global_registry().clear();
1692    }
1693
1694    // TEST 37: GET with one 503 then 200 covers the retry-sleep and 5xx-retry paths.
1695    #[tokio::test]
1696    #[allow(clippy::await_holding_lock)]
1697    async fn real_get_retries_on_503_then_succeeds() {
1698        use axum::{Router, routing::get};
1699        use std::sync::Arc;
1700        use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
1701
1702        let _lock = crate::circuit_breaker::TEST_LOCK
1703            .lock()
1704            .unwrap_or_else(std::sync::PoisonError::into_inner);
1705        crate::circuit_breaker::global_registry().clear();
1706
1707        let hit = Arc::new(AtomicU32::new(0));
1708        let hit2 = hit.clone();
1709        let app = Router::new().route(
1710            "/flaky",
1711            get(move || {
1712                let c = hit2.clone();
1713                async move {
1714                    if c.fetch_add(1, SeqOrdering::SeqCst) == 0 {
1715                        axum::http::StatusCode::SERVICE_UNAVAILABLE
1716                    } else {
1717                        axum::http::StatusCode::OK
1718                    }
1719                }
1720            }),
1721        );
1722        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1723        let addr = listener.local_addr().unwrap();
1724        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1725
1726        // retries(1): 2 total attempts, 100 ms sleep between them.
1727        let resp = Client::new()
1728            .get(format!("http://127.0.0.1:{}/flaky", addr.port()))
1729            .retries(1)
1730            .send()
1731            .await
1732            .unwrap();
1733
1734        assert_eq!(resp.status().as_u16(), 200);
1735        assert_eq!(hit.load(SeqOrdering::SeqCst), 2);
1736
1737        crate::circuit_breaker::global_registry().clear();
1738    }
1739
1740    // TEST 38: text_body sets a plain-text body.
1741    #[test]
1742    fn text_body_sets_body() {
1743        let client = Client::new();
1744        let builder = client.post("https://example.com").text_body("hello");
1745        assert_eq!(builder.body, Some(bytes::Bytes::from_static(b"hello")));
1746    }
1747
1748    // TEST 39: ClientError::NoMock displays correctly.
1749    #[test]
1750    fn client_error_display() {
1751        let err = ClientError::NoMock("GET".to_owned(), "/path".to_owned());
1752        assert!(err.to_string().contains("GET"));
1753        assert!(err.to_string().contains("/path"));
1754    }
1755
1756    // TEST 40: Outbound circuit breaker integration trips and fails fast.
1757    #[tokio::test]
1758    #[allow(clippy::await_holding_lock)]
1759    async fn test_http_client_circuit_breaker_integration() {
1760        use axum::{Router, routing::get};
1761        use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
1762
1763        let _lock = crate::circuit_breaker::TEST_LOCK
1764            .lock()
1765            .unwrap_or_else(std::sync::PoisonError::into_inner);
1766        crate::circuit_breaker::global_registry().clear();
1767
1768        let hit = Arc::new(AtomicU32::new(0));
1769        let hit2 = hit.clone();
1770        let app = Router::new().route(
1771            "/flaky",
1772            get(move || {
1773                let c = hit2.clone();
1774                async move {
1775                    c.fetch_add(1, SeqOrdering::SeqCst);
1776                    axum::http::StatusCode::INTERNAL_SERVER_ERROR
1777                }
1778            }),
1779        );
1780        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1781        let addr = listener.local_addr().unwrap();
1782        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1783
1784        // Build a resilience config with custom thresholds
1785        let mut rc = crate::config::ResilienceConfig::default();
1786        rc.circuit_breaker.defaults.failure_ratio_threshold = Some(0.5);
1787        rc.circuit_breaker.defaults.minimum_sample_count = Some(3);
1788        rc.circuit_breaker.defaults.open_duration_secs = Some(10);
1789
1790        let client = Client::new();
1791        // Attach the resilience config
1792        let client = Client {
1793            resilience_config: Some(Arc::new(rc)),
1794            ..client
1795        };
1796
1797        let url = format!("http://127.0.0.1:{}/flaky", addr.port());
1798
1799        // Send 3 requests (all fail with 500)
1800        for _ in 0..3 {
1801            let res = client.get(&url).send().await;
1802            let res = res.unwrap();
1803            assert_eq!(res.status().as_u16(), 500);
1804        }
1805
1806        // Now the breaker for 127.0.0.1 should be OPEN, and next request should fail fast
1807        let res = client.get(&url).send().await;
1808        assert!(matches!(res, Err(ClientError::CircuitBreakerOpen)));
1809
1810        // Assert that the server was only hit 3 times
1811        assert_eq!(hit.load(SeqOrdering::SeqCst), 3);
1812        crate::circuit_breaker::global_registry().clear();
1813    }
1814}