1use std::future::Future;
4use std::sync::Arc;
5use std::time::Duration;
6
7use http::{HeaderMap, StatusCode};
8use r402_protocol::error::{FacilitatorError, FacilitatorTransportKind};
9use r402_protocol::payment::{
10 SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
11};
12use reqwest::Client;
13use url::Url;
14
15use super::auth::{CreateAuthHeadersCallback, FacilitatorAuthHeaders};
16use super::cache::SupportedCache;
17use super::extension::{attach_settle, attach_verify};
18use super::retry::supported_retry_delay;
19use crate::Facilitator;
20
21#[derive(Clone, Debug)]
23pub struct FacilitatorClient {
24 base_url: Url,
25 verify_url: Url,
26 settle_url: Url,
27 supported_url: Url,
28 client: Client,
29 timeout: Option<Duration>,
30 supported_cache: Option<SupportedCache>,
31 create_auth_headers: Option<CreateAuthHeadersCallback>,
32}
33
34#[derive(Debug, thiserror::Error)]
36pub enum FacilitatorClientError {
37 #[error("URL parse error: {context}: {source}")]
39 UrlParse {
40 context: &'static str,
42 #[source]
44 source: url::ParseError,
45 },
46 #[error("HTTP error: {context}: {source}")]
48 Http {
49 context: &'static str,
51 #[source]
53 source: reqwest::Error,
54 },
55 #[error(
57 "createAuthHeaders must return an object keyed by facilitator path, e.g. \
58 {{ verify: {{ Authorization: \"...\" }}, settle: {{ ... }}, supported: {{ ... }} }}, \
59 but received a flat headers object. See \
60 https://github.com/x402-foundation/x402/issues/2762"
61 )]
62 FlatAuthHeaders,
63 #[error("createAuthHeaders failed: {0}")]
65 Auth(String),
66 #[error("createAuthHeaders {path} header {name} is not a valid string header value")]
68 InvalidAuthHeader {
69 path: &'static str,
71 name: String,
73 },
74}
75
76struct RawHttpResponse {
77 status: StatusCode,
78 body: Vec<u8>,
79 headers: HeaderMap,
80 retry_after: Option<String>,
81}
82
83impl FacilitatorClient {
84 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
86
87 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
89
90 #[must_use]
92 pub const fn base_url(&self) -> &Url {
93 &self.base_url
94 }
95
96 #[must_use]
98 pub const fn verify_url(&self) -> &Url {
99 &self.verify_url
100 }
101
102 #[must_use]
104 pub const fn settle_url(&self) -> &Url {
105 &self.settle_url
106 }
107
108 #[must_use]
110 pub const fn supported_url(&self) -> &Url {
111 &self.supported_url
112 }
113
114 #[must_use]
116 pub const fn timeout(&self) -> Option<&Duration> {
117 self.timeout.as_ref()
118 }
119
120 #[must_use]
122 pub const fn supported_cache(&self) -> Option<&SupportedCache> {
123 self.supported_cache.as_ref()
124 }
125
126 pub fn try_new(mut base_url: Url) -> Result<Self, FacilitatorClientError> {
136 ensure_trailing_slash(&mut base_url);
137 let mut builder = Client::builder().timeout(Self::DEFAULT_TIMEOUT);
138 if is_loopback(&base_url) {
139 builder = builder.no_proxy();
141 }
142 let client = builder.build().map_err(|e| FacilitatorClientError::Http {
143 context: "failed to build reqwest client with default timeout",
144 source: e,
145 })?;
146 let verify_url = join_endpoint(&base_url, "./verify")?;
147 let settle_url = join_endpoint(&base_url, "./settle")?;
148 let supported_url = join_endpoint(&base_url, "./supported")?;
149 Ok(Self {
150 client,
151 base_url,
152 verify_url,
153 settle_url,
154 supported_url,
155 timeout: Some(Self::DEFAULT_TIMEOUT),
156 supported_cache: Some(SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL)),
157 create_auth_headers: None,
158 })
159 }
160
161 #[must_use]
167 pub fn with_auth<F, Fut>(mut self, create: F) -> Self
168 where
169 F: Fn() -> Fut + Send + Sync + 'static,
170 Fut: Future<Output = Result<serde_json::Value, FacilitatorClientError>> + Send + 'static,
171 {
172 self.create_auth_headers = Some(CreateAuthHeadersCallback(Arc::new(move || {
173 Box::pin(create())
174 })));
175 self
176 }
177
178 #[must_use]
180 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
181 self.timeout = Some(timeout);
182 self
183 }
184
185 #[must_use]
187 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
188 self.supported_cache = Some(SupportedCache::new(ttl));
189 self
190 }
191
192 #[must_use]
194 pub fn without_supported_cache(mut self) -> Self {
195 self.supported_cache = None;
196 self
197 }
198
199 pub async fn create_auth_headers(
209 &self,
210 path: &str,
211 ) -> Result<HeaderMap, FacilitatorClientError> {
212 let Some(callback) = &self.create_auth_headers else {
213 return Ok(HeaderMap::new());
214 };
215 let value = (callback.0)().await?;
216 let parsed = FacilitatorAuthHeaders::from_json(&value)?;
217 Ok(parsed.for_path(path))
218 }
219
220 pub async fn verify(
230 &self,
231 request: &VerifyRequest,
232 ) -> Result<VerifyResponse, FacilitatorError> {
233 let headers = self.auth_headers("verify").await?;
234 let raw = self
235 .send(
236 self.client.post(self.verify_url.clone()).json(request),
237 &headers,
238 )
239 .await?;
240 parse_verify_body(&raw)
241 }
242
243 pub async fn settle(
252 &self,
253 request: &SettleRequest,
254 ) -> Result<SettleResponse, FacilitatorError> {
255 let headers = self.auth_headers("settle").await?;
256 let raw = self
257 .send(
258 self.client.post(self.settle_url.clone()).json(request),
259 &headers,
260 )
261 .await?;
262 parse_settle_body(&raw)
263 }
264
265 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorError> {
272 if let Some(cache) = &self.supported_cache
273 && let Some(response) = cache.get().await
274 {
275 return Ok(response);
276 }
277
278 #[cfg(feature = "telemetry")]
279 if self.supported_cache.is_some() {
280 tracing::info!("x402.facilitator_client.supported_cache_miss");
281 }
282
283 let response = self.supported_inner().await?;
284 if let Some(cache) = &self.supported_cache {
285 cache.set(response.clone()).await;
286 }
287 Ok(response)
288 }
289
290 pub async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorError> {
297 let headers = self.auth_headers("supported").await?;
298 let mut raw = self
299 .send(self.client.get(self.supported_url.clone()), &headers)
300 .await?;
301 let mut attempt = 0;
302 while let Some(delay) =
303 supported_retry_delay(raw.status, raw.retry_after.as_deref(), attempt)
304 {
305 #[cfg(feature = "telemetry")]
306 tracing::warn!(
307 attempt,
308 delay_ms = delay.as_millis(),
309 status = raw.status.as_u16(),
310 "x402.facilitator_client.supported_retry",
311 );
312 tokio::time::sleep(delay).await;
313 attempt += 1;
314 raw = self
315 .send(self.client.get(self.supported_url.clone()), &headers)
316 .await?;
317 }
318 parse_supported_body(&raw)
319 }
320
321 async fn auth_headers(&self, path: &'static str) -> Result<HeaderMap, FacilitatorError> {
322 self.create_auth_headers(path)
323 .await
324 .map_err(|_| FacilitatorError::transport(FacilitatorTransportKind::Io))
325 }
326
327 async fn send(
328 &self,
329 mut req: reqwest::RequestBuilder,
330 headers: &HeaderMap,
331 ) -> Result<RawHttpResponse, FacilitatorError> {
332 for (key, value) in headers {
333 req = req.header(key, value);
334 }
335 if let Some(timeout) = self.timeout {
336 req = req.timeout(timeout);
337 }
338 let http_response = req.send().await.map_err(|e| map_reqwest(&e))?;
339 let retry_after = http_response
340 .headers()
341 .get(http::header::RETRY_AFTER)
342 .and_then(|value| value.to_str().ok())
343 .map(ToOwned::to_owned);
344 let status = http_response.status();
345 let response_headers = http_response.headers().clone();
346 let body = http_response
347 .bytes()
348 .await
349 .map_err(|e| map_reqwest(&e))?
350 .to_vec();
351 Ok(RawHttpResponse {
352 status,
353 body,
354 headers: response_headers,
355 retry_after,
356 })
357 }
358}
359
360fn ensure_trailing_slash(url: &mut Url) {
363 let path = url.path();
364 if path.ends_with('/') {
365 return;
366 }
367 let mut with_slash = path.to_owned();
368 with_slash.push('/');
369 url.set_path(&with_slash);
370}
371
372fn is_loopback(url: &Url) -> bool {
373 match url.host() {
374 Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
375 Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
376 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
377 None => false,
378 }
379}
380
381fn join_endpoint(base: &Url, path: &str) -> Result<Url, FacilitatorClientError> {
382 base.join(path)
383 .map_err(|e| FacilitatorClientError::UrlParse {
384 context: "Failed to construct facilitator endpoint URL",
385 source: e,
386 })
387}
388
389fn map_reqwest(err: &reqwest::Error) -> FacilitatorError {
390 if err.is_timeout() {
391 FacilitatorError::transport(FacilitatorTransportKind::Timeout)
392 } else {
393 FacilitatorError::transport(FacilitatorTransportKind::Io)
394 }
395}
396
397const fn status_transport(status: StatusCode) -> FacilitatorError {
398 FacilitatorError::transport(FacilitatorTransportKind::HttpStatus {
399 status: status.as_u16(),
400 })
401}
402
403fn parse_verify_body(raw: &RawHttpResponse) -> Result<VerifyResponse, FacilitatorError> {
404 let parsed = match serde_json::from_slice::<VerifyResponse>(&raw.body) {
405 Ok(parsed) => parsed,
406 Err(_) if raw.status.is_success() => {
407 return Err(FacilitatorError::transport(
408 FacilitatorTransportKind::MalformedSuccessBody,
409 ));
410 }
411 Err(_) => return Err(status_transport(raw.status)),
412 };
413 if raw.status.is_success() || !parsed.is_valid() {
414 return Ok(attach_verify(parsed, &raw.headers));
415 }
416 Err(status_transport(raw.status))
417}
418
419fn parse_settle_body(raw: &RawHttpResponse) -> Result<SettleResponse, FacilitatorError> {
420 let parsed = match serde_json::from_slice::<SettleResponse>(&raw.body) {
421 Ok(parsed) => parsed,
422 Err(_) if raw.status.is_success() => {
423 return Err(FacilitatorError::transport(
424 FacilitatorTransportKind::MalformedSuccessBody,
425 ));
426 }
427 Err(_) => return Err(status_transport(raw.status)),
428 };
429 if raw.status.is_success() {
430 return Ok(attach_settle(parsed, &raw.headers));
431 }
432 if parsed.is_success() {
433 return Err(status_transport(raw.status));
434 }
435 Ok(attach_settle(parsed, &raw.headers))
436}
437
438fn parse_supported_body(raw: &RawHttpResponse) -> Result<SupportedResponse, FacilitatorError> {
439 if raw.status.is_success() {
440 serde_json::from_slice(&raw.body).map_err(|_| {
441 FacilitatorError::transport(FacilitatorTransportKind::MalformedSuccessBody)
442 })
443 } else {
444 Err(status_transport(raw.status))
445 }
446}
447
448impl Facilitator for FacilitatorClient {
449 async fn verify(&self, request: VerifyRequest) -> Result<VerifyResponse, FacilitatorError> {
450 #[cfg(feature = "telemetry")]
451 let result = with_span(
452 Self::verify(self, &request),
453 tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
454 )
455 .await;
456 #[cfg(not(feature = "telemetry"))]
457 let result = Self::verify(self, &request).await;
458 result
459 }
460
461 async fn settle(&self, request: SettleRequest) -> Result<SettleResponse, FacilitatorError> {
462 #[cfg(feature = "telemetry")]
463 let result = with_span(
464 Self::settle(self, &request),
465 tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
466 )
467 .await;
468 #[cfg(not(feature = "telemetry"))]
469 let result = Self::settle(self, &request).await;
470 result
471 }
472
473 async fn supported(&self) -> Result<SupportedResponse, FacilitatorError> {
474 Self::supported(self).await
475 }
476}
477
478impl TryFrom<&str> for FacilitatorClient {
479 type Error = FacilitatorClientError;
480
481 fn try_from(value: &str) -> Result<Self, Self::Error> {
482 let url = Url::parse(value).map_err(|e| FacilitatorClientError::UrlParse {
483 context: "Failed to parse base url",
484 source: e,
485 })?;
486 Self::try_new(url)
487 }
488}
489
490impl TryFrom<String> for FacilitatorClient {
491 type Error = FacilitatorClientError;
492
493 fn try_from(value: String) -> Result<Self, Self::Error> {
494 Self::try_from(value.as_str())
495 }
496}
497
498#[cfg(feature = "telemetry")]
499fn with_span<F: Future>(fut: F, span: tracing::Span) -> impl Future<Output = F::Output> {
500 use tracing::Instrument;
501 fut.instrument(span)
502}