1use std::fmt::Display;
23use std::sync::Arc;
24use std::time::Duration;
25
26use http::{HeaderMap, StatusCode};
27use r402_core::facilitator::{Facilitator, FacilitatorError};
28use r402_core::wire::{
29 SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
30};
31use reqwest::Client;
32use tokio::sync::RwLock;
33#[cfg(feature = "telemetry")]
34use tracing::{Instrument, Span, instrument};
35use url::Url;
36
37#[derive(Clone, Debug)]
39struct SupportedCacheState {
40 response: SupportedResponse,
42 expires_at: std::time::Instant,
44}
45
46#[derive(Debug, Clone)]
52pub struct SupportedCache {
53 ttl: Duration,
55 state: Arc<RwLock<Option<SupportedCacheState>>>,
57}
58
59impl SupportedCache {
60 #[must_use]
62 pub fn new(ttl: Duration) -> Self {
63 Self {
64 ttl,
65 state: Arc::new(RwLock::new(None)),
66 }
67 }
68
69 #[allow(
71 clippy::significant_drop_tightening,
72 reason = "read guard scope matches data access"
73 )]
74 pub async fn get(&self) -> Option<SupportedResponse> {
75 let guard = self.state.read().await;
76 let cache = guard.as_ref()?;
77 if std::time::Instant::now() < cache.expires_at {
78 Some(cache.response.clone())
79 } else {
80 None
81 }
82 }
83
84 pub async fn set(&self, response: SupportedResponse) {
86 let mut guard = self.state.write().await;
87 *guard = Some(SupportedCacheState {
88 response,
89 expires_at: std::time::Instant::now() + self.ttl,
90 });
91 }
92
93 pub async fn clear(&self) {
95 let mut guard = self.state.write().await;
96 *guard = None;
97 }
98}
99
100#[derive(Clone, Debug)]
104pub struct FacilitatorClient {
105 base_url: Url,
107 verify_url: Url,
109 settle_url: Url,
111 supported_url: Url,
113 client: Client,
115 headers: HeaderMap,
117 timeout: Option<Duration>,
119 supported_cache: SupportedCache,
121}
122
123#[derive(Debug, thiserror::Error)]
125pub enum FacilitatorClientError {
126 #[error("URL parse error: {context}: {source}")]
128 UrlParse {
129 context: &'static str,
131 #[source]
133 source: url::ParseError,
134 },
135 #[error("HTTP error: {context}: {source}")]
137 Http {
138 context: &'static str,
140 #[source]
142 source: reqwest::Error,
143 },
144 #[error("Failed to deserialize JSON: {context}: {source}")]
146 JsonDeserialization {
147 context: &'static str,
149 #[source]
151 source: serde_json::Error,
152 body: String,
154 },
155 #[error("Unexpected HTTP status {status}: {context}: {body}")]
157 HttpStatus {
158 context: &'static str,
160 status: StatusCode,
162 body: String,
164 },
165 #[error("Failed to read response body as text: {context}: {source}")]
167 ResponseBodyRead {
168 context: &'static str,
170 #[source]
172 source: reqwest::Error,
173 },
174}
175
176impl FacilitatorClient {
177 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
179
180 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
185
186 #[must_use]
188 pub const fn base_url(&self) -> &Url {
189 &self.base_url
190 }
191
192 #[must_use]
194 pub const fn verify_url(&self) -> &Url {
195 &self.verify_url
196 }
197
198 #[must_use]
200 pub const fn settle_url(&self) -> &Url {
201 &self.settle_url
202 }
203
204 #[must_use]
206 pub const fn supported_url(&self) -> &Url {
207 &self.supported_url
208 }
209
210 #[must_use]
212 pub const fn headers(&self) -> &HeaderMap {
213 &self.headers
214 }
215
216 #[must_use]
218 pub const fn timeout(&self) -> Option<&Duration> {
219 self.timeout.as_ref()
220 }
221
222 #[must_use]
224 pub const fn supported_cache(&self) -> &SupportedCache {
225 &self.supported_cache
226 }
227
228 pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
236 let client = Client::builder()
237 .timeout(Self::DEFAULT_TIMEOUT)
238 .build()
239 .map_err(|e| FacilitatorClientError::Http {
240 context: "failed to build reqwest client with default timeout",
241 source: e,
242 })?;
243 let verify_url =
244 base_url
245 .join("./verify")
246 .map_err(|e| FacilitatorClientError::UrlParse {
247 context: "Failed to construct ./verify URL",
248 source: e,
249 })?;
250 let settle_url =
251 base_url
252 .join("./settle")
253 .map_err(|e| FacilitatorClientError::UrlParse {
254 context: "Failed to construct ./settle URL",
255 source: e,
256 })?;
257 let supported_url =
258 base_url
259 .join("./supported")
260 .map_err(|e| FacilitatorClientError::UrlParse {
261 context: "Failed to construct ./supported URL",
262 source: e,
263 })?;
264 Ok(Self {
265 client,
266 base_url,
267 verify_url,
268 settle_url,
269 supported_url,
270 headers: HeaderMap::new(),
271 timeout: Some(Self::DEFAULT_TIMEOUT),
272 supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
273 })
274 }
275
276 #[must_use]
278 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
279 self.headers = headers;
280 self
281 }
282
283 #[must_use]
285 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
286 self.timeout = Some(timeout);
287 self
288 }
289
290 #[must_use]
294 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
295 self.supported_cache = SupportedCache::new(ttl);
296 self
297 }
298
299 #[must_use]
301 pub fn without_supported_cache(self) -> Self {
302 self.with_supported_cache_ttl(Duration::ZERO)
303 }
304
305 pub async fn verify(
311 &self,
312 request: &VerifyRequest,
313 ) -> Result<VerifyResponse, FacilitatorClientError> {
314 self.post_json(&self.verify_url, "POST /verify", request)
315 .await
316 }
317
318 pub async fn settle(
324 &self,
325 request: &SettleRequest,
326 ) -> Result<SettleResponse, FacilitatorClientError> {
327 self.post_json(&self.settle_url, "POST /settle", request)
328 .await
329 }
330
331 #[cfg_attr(
334 feature = "telemetry",
335 instrument(name = "x402.facilitator_client.supported", skip_all, err)
336 )]
337 async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
338 const MAX_ATTEMPTS: u32 = 3;
343 let mut attempt: u32 = 0;
344 loop {
345 let result = self.get_json(&self.supported_url, "GET /supported").await;
346 match result {
347 Ok(resp) => return Ok(resp),
348 Err(err) => {
349 let retriable = matches!(
350 &err,
351 FacilitatorClientError::HttpStatus { status, .. }
352 if *status == StatusCode::TOO_MANY_REQUESTS
353 || status.is_server_error()
354 );
355 attempt += 1;
356 if !retriable || attempt >= MAX_ATTEMPTS {
357 return Err(err);
358 }
359 let backoff_ms = 200_u64 << attempt;
360 let backoff = Duration::from_millis(backoff_ms);
361 #[cfg(feature = "telemetry")]
362 tracing::warn!(
363 attempt,
364 backoff_ms,
365 error = %err,
366 "x402.facilitator_client.supported_retry",
367 );
368 tokio::time::sleep(backoff).await;
369 }
370 }
371 }
372 }
373
374 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
382 if let Some(response) = self.supported_cache.get().await {
384 return Ok(response);
385 }
386
387 #[cfg(feature = "telemetry")]
389 tracing::info!("x402.facilitator_client.supported_cache_miss");
390
391 let response = self.supported_inner().await?;
392 self.supported_cache.set(response.clone()).await;
393
394 Ok(response)
395 }
396
397 #[allow(
402 clippy::needless_pass_by_value,
403 reason = "context is a static str, clone cost is zero"
404 )]
405 async fn post_json<T, R>(
406 &self,
407 url: &Url,
408 context: &'static str,
409 payload: &T,
410 ) -> Result<R, FacilitatorClientError>
411 where
412 T: serde::Serialize + Sync + ?Sized,
413 R: serde::de::DeserializeOwned,
414 {
415 let req = self.client.post(url.clone()).json(payload);
416 self.send_and_parse(req, context).await
417 }
418
419 async fn get_json<R>(
424 &self,
425 url: &Url,
426 context: &'static str,
427 ) -> Result<R, FacilitatorClientError>
428 where
429 R: serde::de::DeserializeOwned,
430 {
431 let req = self.client.get(url.clone());
432 self.send_and_parse(req, context).await
433 }
434
435 async fn send_and_parse<R>(
437 &self,
438 mut req: reqwest::RequestBuilder,
439 context: &'static str,
440 ) -> Result<R, FacilitatorClientError>
441 where
442 R: serde::de::DeserializeOwned,
443 {
444 for (key, value) in &self.headers {
445 req = req.header(key, value);
446 }
447 if let Some(timeout) = self.timeout {
448 req = req.timeout(timeout);
449 }
450 let http_response = req
451 .send()
452 .await
453 .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
454
455 let status = http_response.status();
461 let body_bytes = http_response
462 .bytes()
463 .await
464 .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
465
466 let result = match serde_json::from_slice::<R>(&body_bytes) {
467 Ok(parsed) => Ok(parsed),
468 Err(parse_err) => {
469 let body = String::from_utf8_lossy(&body_bytes).into_owned();
470 if status.is_success() {
471 Err(FacilitatorClientError::JsonDeserialization {
472 context,
473 source: parse_err,
474 body,
475 })
476 } else {
477 Err(FacilitatorClientError::HttpStatus {
478 context,
479 status,
480 body,
481 })
482 }
483 }
484 };
485
486 record_result_on_span(&result);
487
488 result
489 }
490}
491
492impl Facilitator for FacilitatorClient {
493 async fn verify(&self, request: VerifyRequest) -> Result<VerifyResponse, FacilitatorError> {
494 #[cfg(feature = "telemetry")]
495 let result = with_span(
496 Self::verify(self, &request),
497 tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
498 )
499 .await;
500 #[cfg(not(feature = "telemetry"))]
501 let result = Self::verify(self, &request).await;
502 result.map_err(|e| FacilitatorError::Internal(Box::new(e)))
503 }
504
505 async fn settle(&self, request: SettleRequest) -> Result<SettleResponse, FacilitatorError> {
506 #[cfg(feature = "telemetry")]
507 let result = with_span(
508 Self::settle(self, &request),
509 tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
510 )
511 .await;
512 #[cfg(not(feature = "telemetry"))]
513 let result = Self::settle(self, &request).await;
514 result.map_err(|e| FacilitatorError::Internal(Box::new(e)))
515 }
516
517 async fn supported(&self) -> Result<SupportedResponse, FacilitatorError> {
518 Self::supported(self)
519 .await
520 .map_err(|e| FacilitatorError::Internal(Box::new(e)))
521 }
522}
523
524impl TryFrom<&str> for FacilitatorClient {
526 type Error = FacilitatorClientError;
527
528 fn try_from(value: &str) -> Result<Self, Self::Error> {
529 let mut normalized = value.trim_end_matches('/').to_owned();
531 normalized.push('/');
532 let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
533 context: "Failed to parse base url",
534 source: e,
535 })?;
536 Self::try_new(url)
537 }
538}
539
540impl TryFrom<String> for FacilitatorClient {
542 type Error = FacilitatorClientError;
543
544 fn try_from(value: String) -> Result<Self, Self::Error> {
545 Self::try_from(value.as_str())
546 }
547}
548
549#[cfg(feature = "telemetry")]
551fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
552 let span = Span::current();
553 match result {
554 Ok(_) => {
555 span.record("otel.status_code", "OK");
556 }
557 Err(err) => {
558 span.record("otel.status_code", "ERROR");
559 span.record("error.message", tracing::field::display(err));
560 tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
561 }
562 }
563}
564
565#[cfg(not(feature = "telemetry"))]
568const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
569
570#[cfg(feature = "telemetry")]
572fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
573 fut.instrument(span)
574}
575
576#[cfg(test)]
577#[allow(
578 clippy::indexing_slicing,
579 reason = "test assertions with known-length slices"
580)]
581mod tests {
582 use r402_core::wire::SupportedPaymentKind;
583 use wiremock::matchers::{method, path};
584 use wiremock::{Mock, MockServer, ResponseTemplate};
585
586 use super::*;
587
588 fn create_test_supported_response() -> SupportedResponse {
589 SupportedResponse::new().with_kinds(vec![SupportedPaymentKind::new(1, "eip155-exact", "1")])
590 }
591
592 #[tokio::test]
593 async fn test_supported_cache_caches_response() {
594 let mock_server = MockServer::start().await;
595 let test_response = create_test_supported_response();
596
597 Mock::given(method("GET"))
599 .and(path("/supported"))
600 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
601 .mount(&mock_server)
602 .await;
603
604 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
605
606 let result1 = client.supported().await.unwrap();
608 assert_eq!(result1.kinds.len(), 1);
609
610 let result2 = client.supported().await.unwrap();
612 assert_eq!(result2.kinds.len(), 1);
613
614 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
616 }
617
618 #[tokio::test]
619 async fn test_supported_cache_with_custom_ttl() {
620 let mock_server = MockServer::start().await;
621 let test_response = create_test_supported_response();
622
623 Mock::given(method("GET"))
625 .and(path("/supported"))
626 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
627 .mount(&mock_server)
628 .await;
629
630 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
632 .unwrap()
633 .with_supported_cache_ttl(Duration::from_millis(1));
634
635 let result1 = client.supported().await.unwrap();
637 assert_eq!(result1.kinds.len(), 1);
638
639 tokio::time::sleep(Duration::from_millis(10)).await;
641
642 let result2 = client.supported().await.unwrap();
644 assert_eq!(result2.kinds.len(), 1);
645 }
646
647 #[tokio::test]
648 async fn test_supported_cache_disabled() {
649 let mock_server = MockServer::start().await;
650 let test_response = create_test_supported_response();
651
652 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())
661 .unwrap()
662 .without_supported_cache();
663
664 let result1 = client.supported().await.unwrap();
666 let result2 = client.supported().await.unwrap();
667
668 assert_eq!(result1.kinds.len(), 1);
669 assert_eq!(result2.kinds.len(), 1);
670 }
671
672 #[tokio::test]
673 async fn test_supported_cache_shared_across_clones() {
674 let mock_server = MockServer::start().await;
675 let test_response = create_test_supported_response();
676
677 Mock::given(method("GET"))
679 .and(path("/supported"))
680 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
681 .expect(1)
682 .mount(&mock_server)
683 .await;
684
685 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
686
687 let client2 = client.clone();
689
690 let result1 = client.supported().await.unwrap();
692 assert_eq!(result1.kinds.len(), 1);
693
694 let result2 = client2.supported().await.unwrap();
696 assert_eq!(result2.kinds.len(), 1);
697 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
698 }
699
700 #[tokio::test]
701 async fn test_supported_inner_bypasses_cache() {
702 let mock_server = MockServer::start().await;
703 let test_response = create_test_supported_response();
704
705 Mock::given(method("GET"))
707 .and(path("/supported"))
708 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
709 .mount(&mock_server)
710 .await;
711
712 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
713
714 let _ = client.supported().await.unwrap();
716
717 let result = client.supported_inner().await.unwrap();
719 assert_eq!(result.kinds.len(), 1);
720 }
721}