1use 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#[derive(Clone, Debug)]
40struct SupportedCacheState {
41 response: SupportedResponse,
43 expires_at: std::time::Instant,
45}
46
47#[derive(Debug, Clone)]
53pub struct SupportedCache {
54 ttl: Duration,
56 state: Arc<RwLock<Option<SupportedCacheState>>>,
58}
59
60impl SupportedCache {
61 #[must_use]
63 pub fn new(ttl: Duration) -> Self {
64 Self {
65 ttl,
66 state: Arc::new(RwLock::new(None)),
67 }
68 }
69
70 #[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 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 pub async fn clear(&self) {
96 let mut guard = self.state.write().await;
97 *guard = None;
98 }
99}
100
101#[derive(Clone, Debug)]
105pub struct FacilitatorClient {
106 base_url: Url,
108 verify_url: Url,
110 settle_url: Url,
112 supported_url: Url,
114 client: Client,
116 headers: HeaderMap,
118 timeout: Option<Duration>,
120 supported_cache: SupportedCache,
122}
123
124#[derive(Debug, thiserror::Error)]
126pub enum FacilitatorClientError {
127 #[error("URL parse error: {context}: {source}")]
129 UrlParse {
130 context: &'static str,
132 #[source]
134 source: url::ParseError,
135 },
136 #[error("HTTP error: {context}: {source}")]
138 Http {
139 context: &'static str,
141 #[source]
143 source: reqwest::Error,
144 },
145 #[error("Failed to deserialize JSON: {context}: {source}")]
147 JsonDeserialization {
148 context: &'static str,
150 #[source]
152 source: serde_json::Error,
153 body: String,
155 },
156 #[error("Unexpected HTTP status {status}: {context}: {body}")]
158 HttpStatus {
159 context: &'static str,
161 status: StatusCode,
163 body: String,
165 },
166 #[error("Failed to read response body as text: {context}: {source}")]
168 ResponseBodyRead {
169 context: &'static str,
171 #[source]
173 source: reqwest::Error,
174 },
175}
176
177impl FacilitatorClient {
178 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
180
181 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
186
187 #[must_use]
189 pub const fn base_url(&self) -> &Url {
190 &self.base_url
191 }
192
193 #[must_use]
195 pub const fn verify_url(&self) -> &Url {
196 &self.verify_url
197 }
198
199 #[must_use]
201 pub const fn settle_url(&self) -> &Url {
202 &self.settle_url
203 }
204
205 #[must_use]
207 pub const fn supported_url(&self) -> &Url {
208 &self.supported_url
209 }
210
211 #[must_use]
213 pub const fn headers(&self) -> &HeaderMap {
214 &self.headers
215 }
216
217 #[must_use]
219 pub const fn timeout(&self) -> Option<&Duration> {
220 self.timeout.as_ref()
221 }
222
223 #[must_use]
225 pub const fn supported_cache(&self) -> &SupportedCache {
226 &self.supported_cache
227 }
228
229 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 #[must_use]
279 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
280 self.headers = headers;
281 self
282 }
283
284 #[must_use]
286 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
287 self.timeout = Some(timeout);
288 self
289 }
290
291 #[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 #[must_use]
302 pub fn without_supported_cache(self) -> Self {
303 self.with_supported_cache_ttl(Duration::ZERO)
304 }
305
306 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 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 #[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 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 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
383 if let Some(response) = self.supported_cache.get().await {
385 return Ok(response);
386 }
387
388 #[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 #[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 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 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 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
525impl TryFrom<&str> for FacilitatorClient {
527 type Error = FacilitatorClientError;
528
529 fn try_from(value: &str) -> Result<Self, Self::Error> {
530 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
541impl 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#[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#[cfg(not(feature = "telemetry"))]
569const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
570
571#[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::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 let result1 = client.supported().await.unwrap();
648 assert_eq!(result1.kinds.len(), 1);
649
650 let result2 = client.supported().await.unwrap();
652 assert_eq!(result2.kinds.len(), 1);
653
654 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::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 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
672 .unwrap()
673 .with_supported_cache_ttl(Duration::from_millis(1));
674
675 let result1 = client.supported().await.unwrap();
677 assert_eq!(result1.kinds.len(), 1);
678
679 tokio::time::sleep(Duration::from_millis(10)).await;
681
682 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::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 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
701 .unwrap()
702 .without_supported_cache();
703
704 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::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 let client2 = client.clone();
729
730 let result1 = client.supported().await.unwrap();
732 assert_eq!(result1.kinds.len(), 1);
733
734 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::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 let _ = client.supported().await.unwrap();
756
757 let result = client.supported_inner().await.unwrap();
759 assert_eq!(result.kinds.len(), 1);
760 }
761}