Skip to main content

r402_http/server/
facilitator.rs

1//! A [`r402_core::facilitator::Facilitator`] implementation that interacts with a _remote_ x402 Facilitator over HTTP.
2//!
3//! This [`FacilitatorClient`] handles the `/verify`, `/settle`, and `/supported` endpoints of a remote facilitator,
4//! and implements the [`r402_core::facilitator::Facilitator`] trait for compatibility
5//! with x402-based middleware and logic.
6//!
7//! ## Features
8//!
9//! - Uses `reqwest` for async HTTP requests
10//! - Supports optional timeout and headers
11//! - Integrates with `tracing` if the `telemetry` feature is enabled
12//!
13//! ## Error Handling
14//!
15//! Custom error types capture detailed failure contexts, including
16//! - URL construction
17//! - HTTP transport failures
18//! - JSON deserialization errors
19//! - Unexpected HTTP status responses
20//!
21
22use std::fmt::Display;
23use std::sync::Arc;
24use std::time::Duration;
25
26use http::{HeaderMap, StatusCode};
27use r402_core::error::FacilitatorError;
28use r402_core::facilitator::Facilitator;
29use r402_core::wire::{
30    SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
31};
32use reqwest::Client;
33use tokio::sync::RwLock;
34#[cfg(feature = "telemetry")]
35use tracing::{Instrument, Span, instrument};
36use url::Url;
37
38/// TTL cache for [`SupportedResponse`].
39#[derive(Clone, Debug)]
40struct SupportedCacheState {
41    /// The cached response
42    response: SupportedResponse,
43    /// When the cache expires
44    expires_at: std::time::Instant,
45}
46
47/// An encapsulated TTL cache for the `/supported` endpoint response.
48///
49/// Clones share the same cache state via `Arc`, so cached responses are
50/// visible across all clones (e.g. when the middleware clones the
51/// facilitator per-request).
52#[derive(Debug, Clone)]
53pub struct SupportedCache {
54    /// TTL for the cache
55    ttl: Duration,
56    /// Shared cache state (`Arc<RwLock>` so clones hit the same cache)
57    state: Arc<RwLock<Option<SupportedCacheState>>>,
58}
59
60impl SupportedCache {
61    /// Creates a new cache with the given TTL.
62    #[must_use]
63    pub fn new(ttl: Duration) -> Self {
64        Self {
65            ttl,
66            state: Arc::new(RwLock::new(None)),
67        }
68    }
69
70    /// Returns the cached response if valid, None otherwise.
71    #[allow(
72        clippy::significant_drop_tightening,
73        reason = "read guard scope matches data access"
74    )]
75    pub async fn get(&self) -> Option<SupportedResponse> {
76        let guard = self.state.read().await;
77        let cache = guard.as_ref()?;
78        if std::time::Instant::now() < cache.expires_at {
79            Some(cache.response.clone())
80        } else {
81            None
82        }
83    }
84
85    /// Stores a response in the cache with the configured TTL.
86    pub async fn set(&self, response: SupportedResponse) {
87        let mut guard = self.state.write().await;
88        *guard = Some(SupportedCacheState {
89            response,
90            expires_at: std::time::Instant::now() + self.ttl,
91        });
92    }
93
94    /// Clears the cache.
95    pub async fn clear(&self) {
96        let mut guard = self.state.write().await;
97        *guard = None;
98    }
99}
100
101/// A client for communicating with a remote x402 facilitator.
102///
103/// Handles `/verify`, `/settle`, and `/supported` endpoints via JSON HTTP.
104#[derive(Clone, Debug)]
105pub struct FacilitatorClient {
106    /// Base URL of the facilitator (e.g. `https://facilitator.example/`)
107    base_url: Url,
108    /// Full URL to `POST /verify` requests
109    verify_url: Url,
110    /// Full URL to `POST /settle` requests
111    settle_url: Url,
112    /// Full URL to `GET /supported` requests
113    supported_url: Url,
114    /// Shared Reqwest HTTP client
115    client: Client,
116    /// Optional custom headers sent with each request
117    headers: HeaderMap,
118    /// Optional request timeout
119    timeout: Option<Duration>,
120    /// Cache for the supported endpoint response
121    supported_cache: SupportedCache,
122}
123
124/// Errors that can occur while interacting with a remote facilitator.
125#[derive(Debug, thiserror::Error)]
126pub enum FacilitatorClientError {
127    /// URL parse error.
128    #[error("URL parse error: {context}: {source}")]
129    UrlParse {
130        /// Human-readable context.
131        context: &'static str,
132        /// The underlying parse error.
133        #[source]
134        source: url::ParseError,
135    },
136    /// HTTP transport error.
137    #[error("HTTP error: {context}: {source}")]
138    Http {
139        /// Human-readable context.
140        context: &'static str,
141        /// The underlying reqwest error.
142        #[source]
143        source: reqwest::Error,
144    },
145    /// JSON deserialization error.
146    #[error("Failed to deserialize JSON: {context}: {source}")]
147    JsonDeserialization {
148        /// Human-readable context.
149        context: &'static str,
150        /// The underlying serde error.
151        #[source]
152        source: serde_json::Error,
153        /// The raw body that failed to parse, included for diagnostics.
154        body: String,
155    },
156    /// Unexpected HTTP status code.
157    #[error("Unexpected HTTP status {status}: {context}: {body}")]
158    HttpStatus {
159        /// Human-readable context.
160        context: &'static str,
161        /// The HTTP status code.
162        status: StatusCode,
163        /// The response body.
164        body: String,
165    },
166    /// Failed to read response body.
167    #[error("Failed to read response body as text: {context}: {source}")]
168    ResponseBodyRead {
169        /// Human-readable context.
170        context: &'static str,
171        /// The underlying reqwest error.
172        #[source]
173        source: reqwest::Error,
174    },
175}
176
177impl FacilitatorClient {
178    /// Default TTL for caching the supported endpoint response (10 minutes).
179    pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
180
181    /// Default per-request timeout. Aligned with the Go reference SDK
182    /// (`http.DefaultClient` plus a 30 s wrapper) so a hung remote
183    /// facilitator does not block payment flows indefinitely. Override via
184    /// [`Self::with_timeout`] for stricter SLOs or faster fail-over.
185    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
186
187    /// Returns the base URL used by this client.
188    #[must_use]
189    pub const fn base_url(&self) -> &Url {
190        &self.base_url
191    }
192
193    /// Returns the computed `./verify` URL relative to [`FacilitatorClient::base_url`].
194    #[must_use]
195    pub const fn verify_url(&self) -> &Url {
196        &self.verify_url
197    }
198
199    /// Returns the computed `./settle` URL relative to [`FacilitatorClient::base_url`].
200    #[must_use]
201    pub const fn settle_url(&self) -> &Url {
202        &self.settle_url
203    }
204
205    /// Returns the computed `./supported` URL relative to [`FacilitatorClient::base_url`].
206    #[must_use]
207    pub const fn supported_url(&self) -> &Url {
208        &self.supported_url
209    }
210
211    /// Returns any custom headers configured on the client.
212    #[must_use]
213    pub const fn headers(&self) -> &HeaderMap {
214        &self.headers
215    }
216
217    /// Returns the configured timeout, if any.
218    #[must_use]
219    pub const fn timeout(&self) -> Option<&Duration> {
220        self.timeout.as_ref()
221    }
222
223    /// Returns a reference to the supported cache.
224    #[must_use]
225    pub const fn supported_cache(&self) -> &SupportedCache {
226        &self.supported_cache
227    }
228
229    /// Constructs a new [`FacilitatorClient`] from a base URL.
230    ///
231    /// This sets up `./verify`, `./settle`, and `./supported` endpoint URLs relative to the base.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`FacilitatorClientError`] if URL construction fails.
236    pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
237        let client = Client::builder()
238            .timeout(Self::DEFAULT_TIMEOUT)
239            .build()
240            .map_err(|e| FacilitatorClientError::Http {
241                context: "failed to build reqwest client with default timeout",
242                source: e,
243            })?;
244        let verify_url =
245            base_url
246                .join("./verify")
247                .map_err(|e| FacilitatorClientError::UrlParse {
248                    context: "Failed to construct ./verify URL",
249                    source: e,
250                })?;
251        let settle_url =
252            base_url
253                .join("./settle")
254                .map_err(|e| FacilitatorClientError::UrlParse {
255                    context: "Failed to construct ./settle URL",
256                    source: e,
257                })?;
258        let supported_url =
259            base_url
260                .join("./supported")
261                .map_err(|e| FacilitatorClientError::UrlParse {
262                    context: "Failed to construct ./supported URL",
263                    source: e,
264                })?;
265        Ok(Self {
266            client,
267            base_url,
268            verify_url,
269            settle_url,
270            supported_url,
271            headers: HeaderMap::new(),
272            timeout: Some(Self::DEFAULT_TIMEOUT),
273            supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
274        })
275    }
276
277    /// Attaches custom headers to all future requests.
278    #[must_use]
279    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
280        self.headers = headers;
281        self
282    }
283
284    /// Sets a timeout for all future requests.
285    #[must_use]
286    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
287        self.timeout = Some(timeout);
288        self
289    }
290
291    /// Sets the TTL for caching the supported endpoint response.
292    ///
293    /// Default is 10 minutes. Use [`Self::without_supported_cache()`] to disable caching.
294    #[must_use]
295    pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
296        self.supported_cache = SupportedCache::new(ttl);
297        self
298    }
299
300    /// Disables caching for the supported endpoint.
301    #[must_use]
302    pub fn without_supported_cache(self) -> Self {
303        self.with_supported_cache_ttl(Duration::ZERO)
304    }
305
306    /// Sends a `POST /verify` request to the facilitator.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
311    pub async fn verify(
312        &self,
313        request: &VerifyRequest,
314    ) -> Result<VerifyResponse, FacilitatorClientError> {
315        self.post_json(&self.verify_url, "POST /verify", request)
316            .await
317    }
318
319    /// Sends a `POST /settle` request to the facilitator.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
324    pub async fn settle(
325        &self,
326        request: &SettleRequest,
327    ) -> Result<SettleResponse, FacilitatorClientError> {
328        self.post_json(&self.settle_url, "POST /settle", request)
329            .await
330    }
331
332    /// Sends a `GET /supported` request to the facilitator.
333    /// This is the inner method that always makes an HTTP request.
334    #[cfg_attr(
335        feature = "telemetry",
336        instrument(name = "x402.facilitator_client.supported", skip_all, err)
337    )]
338    async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
339        // F-047: retry rate-limited (`429 Too Many Requests`) and transient
340        // 5xx responses with exponential backoff. The supported endpoint is
341        // idempotent and cached, so retrying is safe and limits flapping
342        // when a facilitator is briefly overloaded.
343        const MAX_ATTEMPTS: u32 = 3;
344        let mut attempt: u32 = 0;
345        loop {
346            let result = self.get_json(&self.supported_url, "GET /supported").await;
347            match result {
348                Ok(resp) => return Ok(resp),
349                Err(err) => {
350                    let retriable = matches!(
351                        &err,
352                        FacilitatorClientError::HttpStatus { status, .. }
353                            if *status == StatusCode::TOO_MANY_REQUESTS
354                                || status.is_server_error()
355                    );
356                    attempt += 1;
357                    if !retriable || attempt >= MAX_ATTEMPTS {
358                        return Err(err);
359                    }
360                    let backoff_ms = 200_u64 << attempt;
361                    let backoff = Duration::from_millis(backoff_ms);
362                    #[cfg(feature = "telemetry")]
363                    tracing::warn!(
364                        attempt,
365                        backoff_ms,
366                        error = %err,
367                        "x402.facilitator_client.supported_retry",
368                    );
369                    tokio::time::sleep(backoff).await;
370                }
371            }
372        }
373    }
374
375    /// Sends a `GET /supported` request to the facilitator.
376    /// Results are cached with a configurable TTL (default: 10 minutes).
377    /// Use `supported_inner()` to bypass the cache.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
382    pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
383        // Try to get from cache
384        if let Some(response) = self.supported_cache.get().await {
385            return Ok(response);
386        }
387
388        // Cache miss - fetch and cache
389        #[cfg(feature = "telemetry")]
390        tracing::info!("x402.facilitator_client.supported_cache_miss");
391
392        let response = self.supported_inner().await?;
393        self.supported_cache.set(response.clone()).await;
394
395        Ok(response)
396    }
397
398    /// Generic POST helper that handles JSON serialization, error mapping,
399    /// timeout application, and telemetry integration.
400    ///
401    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"POST /verify"`).
402    #[allow(
403        clippy::needless_pass_by_value,
404        reason = "context is a static str, clone cost is zero"
405    )]
406    async fn post_json<T, R>(
407        &self,
408        url: &Url,
409        context: &'static str,
410        payload: &T,
411    ) -> Result<R, FacilitatorClientError>
412    where
413        T: serde::Serialize + Sync + ?Sized,
414        R: serde::de::DeserializeOwned,
415    {
416        let req = self.client.post(url.clone()).json(payload);
417        self.send_and_parse(req, context).await
418    }
419
420    /// Generic GET helper that handles error mapping, timeout application,
421    /// and telemetry integration.
422    ///
423    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"GET /supported"`).
424    async fn get_json<R>(
425        &self,
426        url: &Url,
427        context: &'static str,
428    ) -> Result<R, FacilitatorClientError>
429    where
430        R: serde::de::DeserializeOwned,
431    {
432        let req = self.client.get(url.clone());
433        self.send_and_parse(req, context).await
434    }
435
436    /// Applies headers, timeout, sends the request, and parses the JSON response.
437    async fn send_and_parse<R>(
438        &self,
439        mut req: reqwest::RequestBuilder,
440        context: &'static str,
441    ) -> Result<R, FacilitatorClientError>
442    where
443        R: serde::de::DeserializeOwned,
444    {
445        for (key, value) in &self.headers {
446            req = req.header(key, value);
447        }
448        if let Some(timeout) = self.timeout {
449            req = req.timeout(timeout);
450        }
451        let http_response = req
452            .send()
453            .await
454            .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
455
456        // F-044: facilitators MAY return structured `VerifyResponse::Invalid`
457        // or `SettleResponse::Failure` bodies on non-2xx HTTP statuses
458        // (e.g. 402, 412 per x402 v2 §HTTP transport error mapping). We
459        // therefore always attempt to parse the body as the expected `R`,
460        // falling back to `HttpStatus` only when the body cannot be parsed.
461        let status = http_response.status();
462        let body_bytes = http_response
463            .bytes()
464            .await
465            .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
466
467        let result = match serde_json::from_slice::<R>(&body_bytes) {
468            Ok(parsed) => Ok(parsed),
469            Err(parse_err) => {
470                let body = String::from_utf8_lossy(&body_bytes).into_owned();
471                if status.is_success() {
472                    Err(FacilitatorClientError::JsonDeserialization {
473                        context,
474                        source: parse_err,
475                        body,
476                    })
477                } else {
478                    Err(FacilitatorClientError::HttpStatus {
479                        context,
480                        status,
481                        body,
482                    })
483                }
484            }
485        };
486
487        record_result_on_span(&result);
488
489        result
490    }
491}
492
493impl Facilitator for FacilitatorClient {
494    async fn verify(&self, request: VerifyRequest) -> Result<VerifyResponse, FacilitatorError> {
495        #[cfg(feature = "telemetry")]
496        let result = with_span(
497            Self::verify(self, &request),
498            tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
499        )
500        .await;
501        #[cfg(not(feature = "telemetry"))]
502        let result = Self::verify(self, &request).await;
503        result.map_err(|e| FacilitatorError::Internal(Box::new(e)))
504    }
505
506    async fn settle(&self, request: SettleRequest) -> Result<SettleResponse, FacilitatorError> {
507        #[cfg(feature = "telemetry")]
508        let result = with_span(
509            Self::settle(self, &request),
510            tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
511        )
512        .await;
513        #[cfg(not(feature = "telemetry"))]
514        let result = Self::settle(self, &request).await;
515        result.map_err(|e| FacilitatorError::Internal(Box::new(e)))
516    }
517
518    async fn supported(&self) -> Result<SupportedResponse, FacilitatorError> {
519        Self::supported(self)
520            .await
521            .map_err(|e| FacilitatorError::Internal(Box::new(e)))
522    }
523}
524
525/// Converts a string URL into a `FacilitatorClient`, parsing the URL and calling `try_new`.
526impl TryFrom<&str> for FacilitatorClient {
527    type Error = FacilitatorClientError;
528
529    fn try_from(value: &str) -> Result<Self, Self::Error> {
530        // Normalize: strip trailing slashes and add a single trailing slash
531        let mut normalized = value.trim_end_matches('/').to_owned();
532        normalized.push('/');
533        let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
534            context: "Failed to parse base url",
535            source: e,
536        })?;
537        Self::try_new(url)
538    }
539}
540
541/// Converts a String URL into a `FacilitatorClient`.
542impl TryFrom<String> for FacilitatorClient {
543    type Error = FacilitatorClientError;
544
545    fn try_from(value: String) -> Result<Self, Self::Error> {
546        Self::try_from(value.as_str())
547    }
548}
549
550/// Records the outcome of a request on a tracing span, including status and errors.
551#[cfg(feature = "telemetry")]
552fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
553    let span = Span::current();
554    match result {
555        Ok(_) => {
556            span.record("otel.status_code", "OK");
557        }
558        Err(err) => {
559            span.record("otel.status_code", "ERROR");
560            span.record("error.message", tracing::field::display(err));
561            tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
562        }
563    }
564}
565
566/// Records the outcome of a request on a tracing span, including status and errors.
567/// Noop if telemetry feature is off.
568#[cfg(not(feature = "telemetry"))]
569const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
570
571/// Instruments a future with a given tracing span.
572#[cfg(feature = "telemetry")]
573fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
574    fut.instrument(span)
575}
576
577#[cfg(test)]
578#[allow(
579    clippy::indexing_slicing,
580    clippy::expect_used,
581    clippy::panic,
582    reason = "test assertions with known-length slices"
583)]
584mod tests {
585    use r402_core::wire::SupportedPaymentKind;
586    use wiremock::matchers::{method, path};
587    use wiremock::{Mock, MockServer, ResponseTemplate};
588
589    use super::*;
590
591    #[test]
592    fn try_from_str_stores_normalized_base_url() {
593        let client = FacilitatorClient::try_from("https://facilitator.example.com")
594            .expect("valid facilitator URL");
595        assert_eq!(
596            client.base_url().as_str(),
597            "https://facilitator.example.com/"
598        );
599        assert_eq!(
600            client.verify_url().as_str(),
601            "https://facilitator.example.com/verify"
602        );
603        assert_eq!(
604            client.settle_url().as_str(),
605            "https://facilitator.example.com/settle"
606        );
607        assert_eq!(
608            client.supported_url().as_str(),
609            "https://facilitator.example.com/supported"
610        );
611    }
612
613    #[test]
614    fn try_from_str_rejects_invalid_url() {
615        let err = FacilitatorClient::try_from("not a url");
616        assert!(
617            err.is_err(),
618            "invalid facilitator URL must return Err, not panic"
619        );
620        match err {
621            Err(FacilitatorClientError::UrlParse { context, .. }) => {
622                assert_eq!(context, "Failed to parse base url");
623            }
624            other => panic!("expected UrlParse, got {other:?}"),
625        }
626    }
627
628    fn create_test_supported_response() -> SupportedResponse {
629        SupportedResponse::new().with_kinds(vec![SupportedPaymentKind::new(1, "eip155-exact", "1")])
630    }
631
632    #[tokio::test]
633    async fn test_supported_cache_caches_response() {
634        let mock_server = MockServer::start().await;
635        let test_response = create_test_supported_response();
636
637        // Mock the supported endpoint
638        Mock::given(method("GET"))
639            .and(path("/supported"))
640            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
641            .mount(&mock_server)
642            .await;
643
644        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
645
646        // First call should hit the network
647        let result1 = client.supported().await.unwrap();
648        assert_eq!(result1.kinds.len(), 1);
649
650        // Second call should use cache (same mock call count)
651        let result2 = client.supported().await.unwrap();
652        assert_eq!(result2.kinds.len(), 1);
653
654        // Both results should be equal
655        assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
656    }
657
658    #[tokio::test]
659    async fn test_supported_cache_with_custom_ttl() {
660        let mock_server = MockServer::start().await;
661        let test_response = create_test_supported_response();
662
663        // Mock the supported endpoint
664        Mock::given(method("GET"))
665            .and(path("/supported"))
666            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
667            .mount(&mock_server)
668            .await;
669
670        // Create client with 1ms TTL (essentially no caching)
671        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
672            .unwrap()
673            .with_supported_cache_ttl(Duration::from_millis(1));
674
675        // First call
676        let result1 = client.supported().await.unwrap();
677        assert_eq!(result1.kinds.len(), 1);
678
679        // Wait for cache to expire
680        tokio::time::sleep(Duration::from_millis(10)).await;
681
682        // Second call should hit the network again due to expired cache
683        let result2 = client.supported().await.unwrap();
684        assert_eq!(result2.kinds.len(), 1);
685    }
686
687    #[tokio::test]
688    async fn test_supported_cache_disabled() {
689        let mock_server = MockServer::start().await;
690        let test_response = create_test_supported_response();
691
692        // Mock the supported endpoint
693        Mock::given(method("GET"))
694            .and(path("/supported"))
695            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
696            .mount(&mock_server)
697            .await;
698
699        // Create client with caching disabled
700        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
701            .unwrap()
702            .without_supported_cache();
703
704        // Each call should hit the network
705        let result1 = client.supported().await.unwrap();
706        let result2 = client.supported().await.unwrap();
707
708        assert_eq!(result1.kinds.len(), 1);
709        assert_eq!(result2.kinds.len(), 1);
710    }
711
712    #[tokio::test]
713    async fn test_supported_cache_shared_across_clones() {
714        let mock_server = MockServer::start().await;
715        let test_response = create_test_supported_response();
716
717        // Mock the supported endpoint — expect exactly 1 request
718        Mock::given(method("GET"))
719            .and(path("/supported"))
720            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
721            .expect(1)
722            .mount(&mock_server)
723            .await;
724
725        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
726
727        // Clone the client — clones share the same cache
728        let client2 = client.clone();
729
730        // Populate cache on first client
731        let result1 = client.supported().await.unwrap();
732        assert_eq!(result1.kinds.len(), 1);
733
734        // Clone should hit the shared cache (no extra HTTP request)
735        let result2 = client2.supported().await.unwrap();
736        assert_eq!(result2.kinds.len(), 1);
737        assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
738    }
739
740    #[tokio::test]
741    async fn test_supported_inner_bypasses_cache() {
742        let mock_server = MockServer::start().await;
743        let test_response = create_test_supported_response();
744
745        // Mock the supported endpoint
746        Mock::given(method("GET"))
747            .and(path("/supported"))
748            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
749            .mount(&mock_server)
750            .await;
751
752        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
753
754        // Populate cache
755        let _ = client.supported().await.unwrap();
756
757        // supported_inner() should always make HTTP request, bypassing cache
758        let result = client.supported_inner().await.unwrap();
759        assert_eq!(result.kinds.len(), 1);
760    }
761}