use std::fmt::Display;
use std::sync::Arc;
use std::time::Duration;
use http::{HeaderMap, StatusCode};
use r402::facilitator::{BoxFuture, Facilitator, FacilitatorError};
use r402::proto::{
SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
};
use reqwest::Client;
use tokio::sync::RwLock;
#[cfg(feature = "telemetry")]
use tracing::{Instrument, Span, instrument};
use url::Url;
#[derive(Clone, Debug)]
struct SupportedCacheState {
response: SupportedResponse,
expires_at: std::time::Instant,
}
#[derive(Debug, Clone)]
pub struct SupportedCache {
ttl: Duration,
state: Arc<RwLock<Option<SupportedCacheState>>>,
}
impl SupportedCache {
#[must_use]
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
state: Arc::new(RwLock::new(None)),
}
}
#[allow(
clippy::significant_drop_tightening,
reason = "read guard scope matches data access"
)]
pub async fn get(&self) -> Option<SupportedResponse> {
let guard = self.state.read().await;
let cache = guard.as_ref()?;
if std::time::Instant::now() < cache.expires_at {
Some(cache.response.clone())
} else {
None
}
}
pub async fn set(&self, response: SupportedResponse) {
let mut guard = self.state.write().await;
*guard = Some(SupportedCacheState {
response,
expires_at: std::time::Instant::now() + self.ttl,
});
}
pub async fn clear(&self) {
let mut guard = self.state.write().await;
*guard = None;
}
}
#[derive(Clone, Debug)]
pub struct FacilitatorClient {
base_url: Url,
verify_url: Url,
settle_url: Url,
supported_url: Url,
client: Client,
headers: HeaderMap,
timeout: Option<Duration>,
supported_cache: SupportedCache,
}
#[derive(Debug, thiserror::Error)]
pub enum FacilitatorClientError {
#[error("URL parse error: {context}: {source}")]
UrlParse {
context: &'static str,
#[source]
source: url::ParseError,
},
#[error("HTTP error: {context}: {source}")]
Http {
context: &'static str,
#[source]
source: reqwest::Error,
},
#[error("Failed to deserialize JSON: {context}: {source}")]
JsonDeserialization {
context: &'static str,
#[source]
source: reqwest::Error,
},
#[error("Unexpected HTTP status {status}: {context}: {body}")]
HttpStatus {
context: &'static str,
status: StatusCode,
body: String,
},
#[error("Failed to read response body as text: {context}: {source}")]
ResponseBodyRead {
context: &'static str,
#[source]
source: reqwest::Error,
},
}
impl FacilitatorClient {
pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
#[must_use]
pub const fn base_url(&self) -> &Url {
&self.base_url
}
#[must_use]
pub const fn verify_url(&self) -> &Url {
&self.verify_url
}
#[must_use]
pub const fn settle_url(&self) -> &Url {
&self.settle_url
}
#[must_use]
pub const fn supported_url(&self) -> &Url {
&self.supported_url
}
#[must_use]
pub const fn headers(&self) -> &HeaderMap {
&self.headers
}
#[must_use]
pub const fn timeout(&self) -> Option<&Duration> {
self.timeout.as_ref()
}
#[must_use]
pub const fn supported_cache(&self) -> &SupportedCache {
&self.supported_cache
}
pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
let client = Client::new();
let verify_url =
base_url
.join("./verify")
.map_err(|e| FacilitatorClientError::UrlParse {
context: "Failed to construct ./verify URL",
source: e,
})?;
let settle_url =
base_url
.join("./settle")
.map_err(|e| FacilitatorClientError::UrlParse {
context: "Failed to construct ./settle URL",
source: e,
})?;
let supported_url =
base_url
.join("./supported")
.map_err(|e| FacilitatorClientError::UrlParse {
context: "Failed to construct ./supported URL",
source: e,
})?;
Ok(Self {
client,
base_url,
verify_url,
settle_url,
supported_url,
headers: HeaderMap::new(),
timeout: None,
supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
})
}
#[must_use]
pub fn with_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
self.supported_cache = SupportedCache::new(ttl);
self
}
#[must_use]
pub fn without_supported_cache(self) -> Self {
self.with_supported_cache_ttl(Duration::ZERO)
}
pub async fn verify(
&self,
request: &VerifyRequest,
) -> Result<VerifyResponse, FacilitatorClientError> {
self.post_json(&self.verify_url, "POST /verify", request)
.await
}
pub async fn settle(
&self,
request: &SettleRequest,
) -> Result<SettleResponse, FacilitatorClientError> {
self.post_json(&self.settle_url, "POST /settle", request)
.await
}
#[cfg_attr(
feature = "telemetry",
instrument(name = "x402.facilitator_client.supported", skip_all, err)
)]
async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
self.get_json(&self.supported_url, "GET /supported").await
}
pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
if let Some(response) = self.supported_cache.get().await {
return Ok(response);
}
#[cfg(feature = "telemetry")]
tracing::info!("x402.facilitator_client.supported_cache_miss");
let response = self.supported_inner().await?;
self.supported_cache.set(response.clone()).await;
Ok(response)
}
#[allow(
clippy::needless_pass_by_value,
reason = "context is a static str, clone cost is zero"
)]
async fn post_json<T, R>(
&self,
url: &Url,
context: &'static str,
payload: &T,
) -> Result<R, FacilitatorClientError>
where
T: serde::Serialize + Sync + ?Sized,
R: serde::de::DeserializeOwned,
{
let req = self.client.post(url.clone()).json(payload);
self.send_and_parse(req, context).await
}
async fn get_json<R>(
&self,
url: &Url,
context: &'static str,
) -> Result<R, FacilitatorClientError>
where
R: serde::de::DeserializeOwned,
{
let req = self.client.get(url.clone());
self.send_and_parse(req, context).await
}
async fn send_and_parse<R>(
&self,
mut req: reqwest::RequestBuilder,
context: &'static str,
) -> Result<R, FacilitatorClientError>
where
R: serde::de::DeserializeOwned,
{
for (key, value) in &self.headers {
req = req.header(key, value);
}
if let Some(timeout) = self.timeout {
req = req.timeout(timeout);
}
let http_response = req
.send()
.await
.map_err(|e| FacilitatorClientError::Http { context, source: e })?;
let result = if http_response.status() == StatusCode::OK {
http_response
.json::<R>()
.await
.map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
} else {
let status = http_response.status();
let body = http_response
.text()
.await
.map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
Err(FacilitatorClientError::HttpStatus {
context,
status,
body,
})
};
record_result_on_span(&result);
result
}
}
impl Facilitator for FacilitatorClient {
fn verify(
&self,
request: VerifyRequest,
) -> BoxFuture<'_, Result<VerifyResponse, FacilitatorError>> {
Box::pin(async move {
#[cfg(feature = "telemetry")]
let result = with_span(
Self::verify(self, &request),
tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
)
.await;
#[cfg(not(feature = "telemetry"))]
let result = Self::verify(self, &request).await;
result.map_err(|e| FacilitatorError::Other(Box::new(e)))
})
}
fn settle(
&self,
request: SettleRequest,
) -> BoxFuture<'_, Result<SettleResponse, FacilitatorError>> {
Box::pin(async move {
#[cfg(feature = "telemetry")]
let result = with_span(
Self::settle(self, &request),
tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
)
.await;
#[cfg(not(feature = "telemetry"))]
let result = Self::settle(self, &request).await;
result.map_err(|e| FacilitatorError::Other(Box::new(e)))
})
}
fn supported(&self) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
Box::pin(async move {
Self::supported(self)
.await
.map_err(|e| FacilitatorError::Other(Box::new(e)))
})
}
}
impl TryFrom<&str> for FacilitatorClient {
type Error = FacilitatorClientError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let mut normalized = value.trim_end_matches('/').to_owned();
normalized.push('/');
let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
context: "Failed to parse base url",
source: e,
})?;
Self::try_new(url)
}
}
impl TryFrom<String> for FacilitatorClient {
type Error = FacilitatorClientError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
#[cfg(feature = "telemetry")]
fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
let span = Span::current();
match result {
Ok(_) => {
span.record("otel.status_code", "OK");
}
Err(err) => {
span.record("otel.status_code", "ERROR");
span.record("error.message", tracing::field::display(err));
tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
}
}
}
#[cfg(not(feature = "telemetry"))]
const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
#[cfg(feature = "telemetry")]
fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
fut.instrument(span)
}
#[cfg(test)]
#[allow(
clippy::indexing_slicing,
reason = "test assertions with known-length slices"
)]
mod tests {
use std::collections::HashMap;
use r402::proto::SupportedPaymentKind;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn create_test_supported_response() -> SupportedResponse {
SupportedResponse {
kinds: vec![SupportedPaymentKind {
x402_version: 1,
scheme: "eip155-exact".to_owned(),
network: "1".to_owned(),
extra: None,
}],
extensions: vec![],
signers: HashMap::new(),
}
}
#[tokio::test]
async fn test_supported_cache_caches_response() {
let mock_server = MockServer::start().await;
let test_response = create_test_supported_response();
Mock::given(method("GET"))
.and(path("/supported"))
.respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
.mount(&mock_server)
.await;
let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
let result1 = client.supported().await.unwrap();
assert_eq!(result1.kinds.len(), 1);
let result2 = client.supported().await.unwrap();
assert_eq!(result2.kinds.len(), 1);
assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
}
#[tokio::test]
async fn test_supported_cache_with_custom_ttl() {
let mock_server = MockServer::start().await;
let test_response = create_test_supported_response();
Mock::given(method("GET"))
.and(path("/supported"))
.respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
.mount(&mock_server)
.await;
let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
.unwrap()
.with_supported_cache_ttl(Duration::from_millis(1));
let result1 = client.supported().await.unwrap();
assert_eq!(result1.kinds.len(), 1);
tokio::time::sleep(Duration::from_millis(10)).await;
let result2 = client.supported().await.unwrap();
assert_eq!(result2.kinds.len(), 1);
}
#[tokio::test]
async fn test_supported_cache_disabled() {
let mock_server = MockServer::start().await;
let test_response = create_test_supported_response();
Mock::given(method("GET"))
.and(path("/supported"))
.respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
.mount(&mock_server)
.await;
let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
.unwrap()
.without_supported_cache();
let result1 = client.supported().await.unwrap();
let result2 = client.supported().await.unwrap();
assert_eq!(result1.kinds.len(), 1);
assert_eq!(result2.kinds.len(), 1);
}
#[tokio::test]
async fn test_supported_cache_shared_across_clones() {
let mock_server = MockServer::start().await;
let test_response = create_test_supported_response();
Mock::given(method("GET"))
.and(path("/supported"))
.respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
.expect(1)
.mount(&mock_server)
.await;
let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
let client2 = client.clone();
let result1 = client.supported().await.unwrap();
assert_eq!(result1.kinds.len(), 1);
let result2 = client2.supported().await.unwrap();
assert_eq!(result2.kinds.len(), 1);
assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
}
#[tokio::test]
async fn test_supported_inner_bypasses_cache() {
let mock_server = MockServer::start().await;
let test_response = create_test_supported_response();
Mock::given(method("GET"))
.and(path("/supported"))
.respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
.mount(&mock_server)
.await;
let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
let _ = client.supported().await.unwrap();
let result = client.supported_inner().await.unwrap();
assert_eq!(result.kinds.len(), 1);
}
}