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