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