Skip to main content

r402_facilitator/http/
client.rs

1//! HTTP client for a remote x402 facilitator.
2
3use 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/// A client for communicating with a remote x402 facilitator.
22#[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/// Errors that can occur while constructing a client or resolving auth headers.
35#[derive(Debug, thiserror::Error)]
36pub enum FacilitatorClientError {
37    /// URL parse error.
38    #[error("URL parse error: {context}: {source}")]
39    UrlParse {
40        /// Human-readable context.
41        context: &'static str,
42        /// The underlying parse error.
43        #[source]
44        source: url::ParseError,
45    },
46    /// HTTP transport error while building the reqwest client.
47    #[error("HTTP error: {context}: {source}")]
48    Http {
49        /// Human-readable context.
50        context: &'static str,
51        /// The underlying reqwest error.
52        #[source]
53        source: reqwest::Error,
54    },
55    /// Auth callback returned a flat headers object instead of path keys.
56    #[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    /// Auth callback failed before producing headers.
64    #[error("createAuthHeaders failed: {0}")]
65    Auth(String),
66    /// A path-keyed auth object contained a non-string or illegal header.
67    #[error("createAuthHeaders {path} header {name} is not a valid string header value")]
68    InvalidAuthHeader {
69        /// Facilitator path (`verify`, `settle`, `supported`, or `bazaar`).
70        path: &'static str,
71        /// Header name as given in the JSON object.
72        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    /// Default `/supported` cache TTL used by [`Self::try_new`].
85    pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
86
87    /// Default per-request timeout. Aligned with the Go/TS clients (30 s).
88    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
89
90    /// Returns the base URL used by this client.
91    #[must_use]
92    pub const fn base_url(&self) -> &Url {
93        &self.base_url
94    }
95
96    /// Returns the computed `./verify` URL relative to [`Self::base_url`].
97    #[must_use]
98    pub const fn verify_url(&self) -> &Url {
99        &self.verify_url
100    }
101
102    /// Returns the computed `./settle` URL relative to [`Self::base_url`].
103    #[must_use]
104    pub const fn settle_url(&self) -> &Url {
105        &self.settle_url
106    }
107
108    /// Returns the computed `./supported` URL relative to [`Self::base_url`].
109    #[must_use]
110    pub const fn supported_url(&self) -> &Url {
111        &self.supported_url
112    }
113
114    /// Returns the configured timeout, if any.
115    #[must_use]
116    pub const fn timeout(&self) -> Option<&Duration> {
117        self.timeout.as_ref()
118    }
119
120    /// Returns the supported cache when one is configured.
121    #[must_use]
122    pub const fn supported_cache(&self) -> Option<&SupportedCache> {
123        self.supported_cache.as_ref()
124    }
125
126    /// Constructs a new [`FacilitatorClient`] from a base URL.
127    ///
128    /// Sets up `./verify`, `./settle`, and `./supported` relative to the base.
129    /// `/supported` is cached for [`Self::DEFAULT_SUPPORTED_CACHE_TTL`].
130    /// Call [`Self::without_supported_cache`] to disable.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`FacilitatorClientError`] if URL construction fails.
135    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            // HTTP_PROXY must not capture loopback mock servers or local facilitators.
140            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    /// Sets the callback that produces path-keyed facilitator auth headers.
162    ///
163    /// The JSON object must be keyed by `verify`, `settle`, `supported`, and
164    /// optionally `bazaar`. A flat headers object is rejected when headers
165    /// are resolved.
166    #[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    /// Sets a timeout for all future requests.
179    #[must_use]
180    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
181        self.timeout = Some(timeout);
182        self
183    }
184
185    /// Enables TTL caching of the `/supported` response.
186    #[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    /// Disables caching for the `/supported` endpoint.
193    #[must_use]
194    pub fn without_supported_cache(mut self) -> Self {
195        self.supported_cache = None;
196        self
197    }
198
199    /// Resolves authentication headers for one facilitator path.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`FacilitatorClientError::FlatAuthHeaders`] when the callback
204    /// result has no object-valued `verify`/`settle`/`supported`/`bazaar` key
205    /// and some value is not a header object. Returns
206    /// [`FacilitatorClientError::InvalidAuthHeader`] when a path object has a
207    /// non-string or illegal header. Propagates callback `Err` values.
208    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    /// Sends a `POST /verify` request to the facilitator.
221    ///
222    /// 2xx or non-2xx `Invalid` JSON is `Ok`. Non-2xx `Valid` JSON, timeout,
223    /// connect/DNS/TLS/body failures, and malformed 2xx bodies are
224    /// [`FacilitatorError::Transport`] (502).
225    ///
226    /// # Errors
227    ///
228    /// Returns [`FacilitatorError`] for transport, auth, or parse failures.
229    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    /// Sends a `POST /settle` request to the facilitator.
244    ///
245    /// 2xx `Success` may have `transaction: ""`. Non-2xx `Failure` is `Ok`.
246    /// Non-2xx `Success` is Transport.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`FacilitatorError`] for transport, auth, or parse failures.
251    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    /// Sends a `GET /supported` request to the facilitator.
266    /// Results are cached when a TTL is configured (the default).
267    ///
268    /// # Errors
269    ///
270    /// Returns [`FacilitatorError`] if the HTTP request fails.
271    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    /// GET `/supported` with 429-only retries, bypassing the TTL cache.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`FacilitatorError`] if the HTTP request fails or the body is
295    /// not a well-formed [`SupportedResponse`].
296    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
360/// `Url::join("./verify")` replaces the last path segment unless the base ends
361/// with `/` (`https://x402.org/facilitator` would become `/verify`).
362fn 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}