Skip to main content

r402_http/server/
facilitator.rs

1//! A [`r402::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::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::time::Duration;
24
25use http::{HeaderMap, StatusCode};
26use r402::facilitator::{BoxFuture, Facilitator, FacilitatorError};
27use r402::proto::{
28    SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
29};
30use reqwest::Client;
31use tokio::sync::RwLock;
32#[cfg(feature = "telemetry")]
33use tracing::{Instrument, Span, instrument};
34use url::Url;
35
36/// TTL cache for [`SupportedResponse`].
37#[derive(Clone, Debug)]
38struct SupportedCacheState {
39    /// The cached response
40    response: SupportedResponse,
41    /// When the cache expires
42    expires_at: std::time::Instant,
43}
44
45/// An encapsulated TTL cache for the `/supported` endpoint response.
46///
47/// Each clone has an independent cache state.
48#[derive(Debug)]
49pub struct SupportedCache {
50    /// TTL for the cache
51    ttl: Duration,
52    /// Cache state (`RwLock` for read-heavy workload)
53    state: RwLock<Option<SupportedCacheState>>,
54}
55
56impl SupportedCache {
57    /// Creates a new cache with the given TTL.
58    #[must_use]
59    pub fn new(ttl: Duration) -> Self {
60        Self {
61            ttl,
62            state: RwLock::new(None),
63        }
64    }
65
66    /// Returns the cached response if valid, None otherwise.
67    pub async fn get(&self) -> Option<SupportedResponse> {
68        let guard = self.state.read().await;
69        let cache = guard.as_ref()?;
70        if std::time::Instant::now() < cache.expires_at {
71            Some(cache.response.clone())
72        } else {
73            None
74        }
75    }
76
77    /// Stores a response in the cache with the configured TTL.
78    pub async fn set(&self, response: SupportedResponse) {
79        let mut guard = self.state.write().await;
80        *guard = Some(SupportedCacheState {
81            response,
82            expires_at: std::time::Instant::now() + self.ttl,
83        });
84    }
85
86    /// Clears the cache.
87    pub async fn clear(&self) {
88        let mut guard = self.state.write().await;
89        *guard = None;
90    }
91}
92
93impl Clone for SupportedCache {
94    fn clone(&self) -> Self {
95        Self::new(self.ttl)
96    }
97}
98
99/// A client for communicating with a remote x402 facilitator.
100///
101/// Handles `/verify`, `/settle`, and `/supported` endpoints via JSON HTTP.
102#[derive(Clone, Debug)]
103pub struct FacilitatorClient {
104    /// Base URL of the facilitator (e.g. `https://facilitator.example/`)
105    base_url: Url,
106    /// Full URL to `POST /verify` requests
107    verify_url: Url,
108    /// Full URL to `POST /settle` requests
109    settle_url: Url,
110    /// Full URL to `GET /supported` requests
111    supported_url: Url,
112    /// Shared Reqwest HTTP client
113    client: Client,
114    /// Optional custom headers sent with each request
115    headers: HeaderMap,
116    /// Optional request timeout
117    timeout: Option<Duration>,
118    /// Cache for the supported endpoint response
119    supported_cache: SupportedCache,
120}
121
122impl Facilitator for FacilitatorClient {
123    fn verify(
124        &self,
125        request: VerifyRequest,
126    ) -> BoxFuture<'_, Result<VerifyResponse, FacilitatorError>> {
127        Box::pin(async move {
128            #[cfg(feature = "telemetry")]
129            let result = with_span(
130                Self::verify(self, &request),
131                tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
132            )
133            .await;
134            #[cfg(not(feature = "telemetry"))]
135            let result = Self::verify(self, &request).await;
136            result.map_err(|e| FacilitatorError::Other(Box::new(e)))
137        })
138    }
139
140    fn settle(
141        &self,
142        request: SettleRequest,
143    ) -> BoxFuture<'_, Result<SettleResponse, FacilitatorError>> {
144        Box::pin(async move {
145            #[cfg(feature = "telemetry")]
146            let result = with_span(
147                Self::settle(self, &request),
148                tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
149            )
150            .await;
151            #[cfg(not(feature = "telemetry"))]
152            let result = Self::settle(self, &request).await;
153            result.map_err(|e| FacilitatorError::Other(Box::new(e)))
154        })
155    }
156
157    fn supported(
158        &self,
159    ) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
160        Box::pin(async move {
161            Self::supported(self)
162                .await
163                .map_err(|e| FacilitatorError::Other(Box::new(e)))
164        })
165    }
166}
167
168/// Errors that can occur while interacting with a remote facilitator.
169#[derive(Debug, thiserror::Error)]
170pub enum FacilitatorClientError {
171    /// URL parse error.
172    #[error("URL parse error: {context}: {source}")]
173    UrlParse {
174        /// Human-readable context.
175        context: &'static str,
176        /// The underlying parse error.
177        #[source]
178        source: url::ParseError,
179    },
180    /// HTTP transport error.
181    #[error("HTTP error: {context}: {source}")]
182    Http {
183        /// Human-readable context.
184        context: &'static str,
185        /// The underlying reqwest error.
186        #[source]
187        source: reqwest::Error,
188    },
189    /// JSON deserialization error.
190    #[error("Failed to deserialize JSON: {context}: {source}")]
191    JsonDeserialization {
192        /// Human-readable context.
193        context: &'static str,
194        /// The underlying reqwest error.
195        #[source]
196        source: reqwest::Error,
197    },
198    /// Unexpected HTTP status code.
199    #[error("Unexpected HTTP status {status}: {context}: {body}")]
200    HttpStatus {
201        /// Human-readable context.
202        context: &'static str,
203        /// The HTTP status code.
204        status: StatusCode,
205        /// The response body.
206        body: String,
207    },
208    /// Failed to read response body.
209    #[error("Failed to read response body as text: {context}: {source}")]
210    ResponseBodyRead {
211        /// Human-readable context.
212        context: &'static str,
213        /// The underlying reqwest error.
214        #[source]
215        source: reqwest::Error,
216    },
217}
218
219impl FacilitatorClient {
220    /// Default TTL for caching the supported endpoint response (10 minutes).
221    pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
222
223    /// Returns the base URL used by this client.
224    pub const fn base_url(&self) -> &Url {
225        &self.base_url
226    }
227
228    /// Returns the computed `./verify` URL relative to [`FacilitatorClient::base_url`].
229    pub const fn verify_url(&self) -> &Url {
230        &self.verify_url
231    }
232
233    /// Returns the computed `./settle` URL relative to [`FacilitatorClient::base_url`].
234    pub const fn settle_url(&self) -> &Url {
235        &self.settle_url
236    }
237
238    /// Returns the computed `./supported` URL relative to [`FacilitatorClient::base_url`].
239    pub const fn supported_url(&self) -> &Url {
240        &self.supported_url
241    }
242
243    /// Returns any custom headers configured on the client.
244    pub const fn headers(&self) -> &HeaderMap {
245        &self.headers
246    }
247
248    /// Returns the configured timeout, if any.
249    pub const fn timeout(&self) -> &Option<Duration> {
250        &self.timeout
251    }
252
253    /// Returns a reference to the supported cache.
254    pub const fn supported_cache(&self) -> &SupportedCache {
255        &self.supported_cache
256    }
257
258    /// Constructs a new [`FacilitatorClient`] from a base URL.
259    ///
260    /// This sets up `./verify`, `./settle`, and `./supported` endpoint URLs relative to the base.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`FacilitatorClientError`] if URL construction fails.
265    pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
266        let client = Client::new();
267        let verify_url =
268            base_url
269                .join("./verify")
270                .map_err(|e| FacilitatorClientError::UrlParse {
271                    context: "Failed to construct ./verify URL",
272                    source: e,
273                })?;
274        let settle_url =
275            base_url
276                .join("./settle")
277                .map_err(|e| FacilitatorClientError::UrlParse {
278                    context: "Failed to construct ./settle URL",
279                    source: e,
280                })?;
281        let supported_url =
282            base_url
283                .join("./supported")
284                .map_err(|e| FacilitatorClientError::UrlParse {
285                    context: "Failed to construct ./supported URL",
286                    source: e,
287                })?;
288        Ok(Self {
289            client,
290            base_url,
291            verify_url,
292            settle_url,
293            supported_url,
294            headers: HeaderMap::new(),
295            timeout: None,
296            supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
297        })
298    }
299
300    /// Attaches custom headers to all future requests.
301    #[must_use]
302    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
303        self.headers = headers;
304        self
305    }
306
307    /// Sets a timeout for all future requests.
308    #[must_use]
309    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
310        self.timeout = Some(timeout);
311        self
312    }
313
314    /// Sets the TTL for caching the supported endpoint response.
315    ///
316    /// Default is 10 minutes. Use [`Self::without_supported_cache()`] to disable caching.
317    #[must_use]
318    pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
319        self.supported_cache = SupportedCache::new(ttl);
320        self
321    }
322
323    /// Disables caching for the supported endpoint.
324    #[must_use]
325    pub fn without_supported_cache(self) -> Self {
326        self.with_supported_cache_ttl(Duration::ZERO)
327    }
328
329    /// Sends a `POST /verify` request to the facilitator.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
334    pub async fn verify(
335        &self,
336        request: &VerifyRequest,
337    ) -> Result<VerifyResponse, FacilitatorClientError> {
338        self.post_json(&self.verify_url, "POST /verify", request)
339            .await
340    }
341
342    /// Sends a `POST /settle` request to the facilitator.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
347    pub async fn settle(
348        &self,
349        request: &SettleRequest,
350    ) -> Result<SettleResponse, FacilitatorClientError> {
351        self.post_json(&self.settle_url, "POST /settle", request)
352            .await
353    }
354
355    /// Sends a `GET /supported` request to the facilitator.
356    /// This is the inner method that always makes an HTTP request.
357    #[cfg_attr(
358        feature = "telemetry",
359        instrument(name = "x402.facilitator_client.supported", skip_all, err)
360    )]
361    async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
362        self.get_json(&self.supported_url, "GET /supported").await
363    }
364
365    /// Sends a `GET /supported` request to the facilitator.
366    /// Results are cached with a configurable TTL (default: 10 minutes).
367    /// Use `supported_inner()` to bypass the cache.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
372    pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
373        // Try to get from cache
374        if let Some(response) = self.supported_cache.get().await {
375            return Ok(response);
376        }
377
378        // Cache miss - fetch and cache
379        #[cfg(feature = "telemetry")]
380        tracing::info!("x402.facilitator_client.supported_cache_miss");
381
382        let response = self.supported_inner().await?;
383        self.supported_cache.set(response.clone()).await;
384
385        Ok(response)
386    }
387
388    /// Generic POST helper that handles JSON serialization, error mapping,
389    /// timeout application, and telemetry integration.
390    ///
391    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"POST /verify"`).
392    #[allow(clippy::needless_pass_by_value)]
393    async fn post_json<T, R>(
394        &self,
395        url: &Url,
396        context: &'static str,
397        payload: &T,
398    ) -> Result<R, FacilitatorClientError>
399    where
400        T: serde::Serialize + Sync + ?Sized,
401        R: serde::de::DeserializeOwned,
402    {
403        let req = self.client.post(url.clone()).json(payload);
404        self.send_and_parse(req, context).await
405    }
406
407    /// Generic GET helper that handles error mapping, timeout application,
408    /// and telemetry integration.
409    ///
410    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"GET /supported"`).
411    async fn get_json<R>(
412        &self,
413        url: &Url,
414        context: &'static str,
415    ) -> Result<R, FacilitatorClientError>
416    where
417        R: serde::de::DeserializeOwned,
418    {
419        let req = self.client.get(url.clone());
420        self.send_and_parse(req, context).await
421    }
422
423    /// Applies headers, timeout, sends the request, and parses the JSON response.
424    async fn send_and_parse<R>(
425        &self,
426        mut req: reqwest::RequestBuilder,
427        context: &'static str,
428    ) -> Result<R, FacilitatorClientError>
429    where
430        R: serde::de::DeserializeOwned,
431    {
432        for (key, value) in &self.headers {
433            req = req.header(key, value);
434        }
435        if let Some(timeout) = self.timeout {
436            req = req.timeout(timeout);
437        }
438        let http_response = req
439            .send()
440            .await
441            .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
442
443        let result = if http_response.status() == StatusCode::OK {
444            http_response
445                .json::<R>()
446                .await
447                .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
448        } else {
449            let status = http_response.status();
450            let body = http_response
451                .text()
452                .await
453                .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
454            Err(FacilitatorClientError::HttpStatus {
455                context,
456                status,
457                body,
458            })
459        };
460
461        record_result_on_span(&result);
462
463        result
464    }
465}
466
467/// Converts a string URL into a `FacilitatorClient`, parsing the URL and calling `try_new`.
468impl TryFrom<&str> for FacilitatorClient {
469    type Error = FacilitatorClientError;
470
471    fn try_from(value: &str) -> Result<Self, Self::Error> {
472        // Normalize: strip trailing slashes and add a single trailing slash
473        let mut normalized = value.trim_end_matches('/').to_string();
474        normalized.push('/');
475        let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
476            context: "Failed to parse base url",
477            source: e,
478        })?;
479        Self::try_new(url)
480    }
481}
482
483/// Converts a String URL into a `FacilitatorClient`.
484impl TryFrom<String> for FacilitatorClient {
485    type Error = FacilitatorClientError;
486
487    fn try_from(value: String) -> Result<Self, Self::Error> {
488        Self::try_from(value.as_str())
489    }
490}
491
492/// Records the outcome of a request on a tracing span, including status and errors.
493#[cfg(feature = "telemetry")]
494fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
495    let span = Span::current();
496    match result {
497        Ok(_) => {
498            span.record("otel.status_code", "OK");
499        }
500        Err(err) => {
501            span.record("otel.status_code", "ERROR");
502            span.record("error.message", tracing::field::display(err));
503            tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
504        }
505    }
506}
507
508/// Records the outcome of a request on a tracing span, including status and errors.
509/// Noop if telemetry feature is off.
510#[cfg(not(feature = "telemetry"))]
511fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
512
513/// Instruments a future with a given tracing span.
514#[cfg(feature = "telemetry")]
515fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
516    fut.instrument(span)
517}
518
519#[cfg(test)]
520mod tests {
521    use std::collections::HashMap;
522
523    use r402::proto::SupportedPaymentKind;
524    use wiremock::matchers::{method, path};
525    use wiremock::{Mock, MockServer, ResponseTemplate};
526
527    use super::*;
528
529    fn create_test_supported_response() -> SupportedResponse {
530        SupportedResponse {
531            kinds: vec![SupportedPaymentKind {
532                x402_version: 1,
533                scheme: "eip155-exact".to_string(),
534                network: "1".to_string(),
535                extra: None,
536            }],
537            extensions: vec![],
538            signers: HashMap::new(),
539        }
540    }
541
542    #[tokio::test]
543    async fn test_supported_cache_caches_response() {
544        let mock_server = MockServer::start().await;
545        let test_response = create_test_supported_response();
546
547        // Mock the supported endpoint
548        Mock::given(method("GET"))
549            .and(path("/supported"))
550            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
551            .mount(&mock_server)
552            .await;
553
554        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
555
556        // First call should hit the network
557        let result1 = client.supported().await.unwrap();
558        assert_eq!(result1.kinds.len(), 1);
559
560        // Second call should use cache (same mock call count)
561        let result2 = client.supported().await.unwrap();
562        assert_eq!(result2.kinds.len(), 1);
563
564        // Both results should be equal
565        assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
566    }
567
568    #[tokio::test]
569    async fn test_supported_cache_with_custom_ttl() {
570        let mock_server = MockServer::start().await;
571        let test_response = create_test_supported_response();
572
573        // Mock the supported endpoint
574        Mock::given(method("GET"))
575            .and(path("/supported"))
576            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
577            .mount(&mock_server)
578            .await;
579
580        // Create client with 1ms TTL (essentially no caching)
581        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
582            .unwrap()
583            .with_supported_cache_ttl(Duration::from_millis(1));
584
585        // First call
586        let result1 = client.supported().await.unwrap();
587        assert_eq!(result1.kinds.len(), 1);
588
589        // Wait for cache to expire
590        tokio::time::sleep(Duration::from_millis(10)).await;
591
592        // Second call should hit the network again due to expired cache
593        let result2 = client.supported().await.unwrap();
594        assert_eq!(result2.kinds.len(), 1);
595    }
596
597    #[tokio::test]
598    async fn test_supported_cache_disabled() {
599        let mock_server = MockServer::start().await;
600        let test_response = create_test_supported_response();
601
602        // Mock the supported endpoint
603        Mock::given(method("GET"))
604            .and(path("/supported"))
605            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
606            .mount(&mock_server)
607            .await;
608
609        // Create client with caching disabled
610        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
611            .unwrap()
612            .without_supported_cache();
613
614        // Each call should hit the network
615        let result1 = client.supported().await.unwrap();
616        let result2 = client.supported().await.unwrap();
617
618        assert_eq!(result1.kinds.len(), 1);
619        assert_eq!(result2.kinds.len(), 1);
620    }
621
622    #[tokio::test]
623    async fn test_supported_cache_clones_independently() {
624        let mock_server = MockServer::start().await;
625        let test_response = create_test_supported_response();
626
627        // Mock the supported endpoint
628        Mock::given(method("GET"))
629            .and(path("/supported"))
630            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
631            .mount(&mock_server)
632            .await;
633
634        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
635
636        // Clone the client
637        let client2 = client.clone();
638
639        // Populate cache on first client
640        let _ = client.supported().await.unwrap();
641
642        // Clone should have independent cache (will make its own request)
643        // Note: Since both clones point to same server, the mock will count 2 requests
644        let _ = client2.supported().await.unwrap();
645    }
646
647    #[tokio::test]
648    async fn test_supported_inner_bypasses_cache() {
649        let mock_server = MockServer::start().await;
650        let test_response = create_test_supported_response();
651
652        // Mock the supported endpoint
653        Mock::given(method("GET"))
654            .and(path("/supported"))
655            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
656            .mount(&mock_server)
657            .await;
658
659        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
660
661        // Populate cache
662        let _ = client.supported().await.unwrap();
663
664        // supported_inner() should always make HTTP request, bypassing cache
665        let result = client.supported_inner().await.unwrap();
666        assert_eq!(result.kinds.len(), 1);
667    }
668}