Skip to main content

r402_http/server/
facilitator.rs

1//! A [`r402_core::facilitator::Facilitator`] implementation that interacts with a _remote_ x402 Facilitator over HTTP.
2//!
3//! This [`FacilitatorClient`] handles the `/verify`, `/settle`, and `/supported` endpoints of a remote facilitator,
4//! and implements the [`r402_core::facilitator::Facilitator`] trait for compatibility
5//! with x402-based middleware and logic.
6//!
7//! ## Features
8//!
9//! - Uses `reqwest` for async HTTP requests
10//! - Supports optional timeout and headers
11//! - Integrates with `tracing` if the `telemetry` feature is enabled
12//!
13//! ## Error Handling
14//!
15//! Custom error types capture detailed failure contexts, including
16//! - URL construction
17//! - HTTP transport failures
18//! - JSON deserialization errors
19//! - Unexpected HTTP status responses
20//!
21
22use 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/// TTL cache for [`SupportedResponse`].
38#[derive(Clone, Debug)]
39struct SupportedCacheState {
40    /// The cached response
41    response: SupportedResponse,
42    /// When the cache expires
43    expires_at: std::time::Instant,
44}
45
46/// An encapsulated TTL cache for the `/supported` endpoint response.
47///
48/// Clones share the same cache state via `Arc`, so cached responses are
49/// visible across all clones (e.g. when the middleware clones the
50/// facilitator per-request).
51#[derive(Debug, Clone)]
52pub struct SupportedCache {
53    /// TTL for the cache
54    ttl: Duration,
55    /// Shared cache state (`Arc<RwLock>` so clones hit the same cache)
56    state: Arc<RwLock<Option<SupportedCacheState>>>,
57}
58
59impl SupportedCache {
60    /// Creates a new cache with the given TTL.
61    #[must_use]
62    pub fn new(ttl: Duration) -> Self {
63        Self {
64            ttl,
65            state: Arc::new(RwLock::new(None)),
66        }
67    }
68
69    /// Returns the cached response if valid, None otherwise.
70    #[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    /// Stores a response in the cache with the configured TTL.
85    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    /// Clears the cache.
94    pub async fn clear(&self) {
95        let mut guard = self.state.write().await;
96        *guard = None;
97    }
98}
99
100/// A client for communicating with a remote x402 facilitator.
101///
102/// Handles `/verify`, `/settle`, and `/supported` endpoints via JSON HTTP.
103#[derive(Clone, Debug)]
104pub struct FacilitatorClient {
105    /// Base URL of the facilitator (e.g. `https://facilitator.example/`)
106    base_url: Url,
107    /// Full URL to `POST /verify` requests
108    verify_url: Url,
109    /// Full URL to `POST /settle` requests
110    settle_url: Url,
111    /// Full URL to `GET /supported` requests
112    supported_url: Url,
113    /// Shared Reqwest HTTP client
114    client: Client,
115    /// Optional custom headers sent with each request
116    headers: HeaderMap,
117    /// Optional request timeout
118    timeout: Option<Duration>,
119    /// Cache for the supported endpoint response
120    supported_cache: SupportedCache,
121}
122
123/// Errors that can occur while interacting with a remote facilitator.
124#[derive(Debug, thiserror::Error)]
125pub enum FacilitatorClientError {
126    /// URL parse error.
127    #[error("URL parse error: {context}: {source}")]
128    UrlParse {
129        /// Human-readable context.
130        context: &'static str,
131        /// The underlying parse error.
132        #[source]
133        source: url::ParseError,
134    },
135    /// HTTP transport error.
136    #[error("HTTP error: {context}: {source}")]
137    Http {
138        /// Human-readable context.
139        context: &'static str,
140        /// The underlying reqwest error.
141        #[source]
142        source: reqwest::Error,
143    },
144    /// JSON deserialization error.
145    #[error("Failed to deserialize JSON: {context}: {source}")]
146    JsonDeserialization {
147        /// Human-readable context.
148        context: &'static str,
149        /// The underlying serde error.
150        #[source]
151        source: serde_json::Error,
152        /// The raw body that failed to parse, included for diagnostics.
153        body: String,
154    },
155    /// Unexpected HTTP status code.
156    #[error("Unexpected HTTP status {status}: {context}: {body}")]
157    HttpStatus {
158        /// Human-readable context.
159        context: &'static str,
160        /// The HTTP status code.
161        status: StatusCode,
162        /// The response body.
163        body: String,
164    },
165    /// Failed to read response body.
166    #[error("Failed to read response body as text: {context}: {source}")]
167    ResponseBodyRead {
168        /// Human-readable context.
169        context: &'static str,
170        /// The underlying reqwest error.
171        #[source]
172        source: reqwest::Error,
173    },
174}
175
176impl FacilitatorClient {
177    /// Default TTL for caching the supported endpoint response (10 minutes).
178    pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
179
180    /// Default per-request timeout. Aligned with the Go reference SDK
181    /// (`http.DefaultClient` plus a 30 s wrapper) so a hung remote
182    /// facilitator does not block payment flows indefinitely. Override via
183    /// [`Self::with_timeout`] for stricter SLOs or faster fail-over.
184    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
185
186    /// Returns the base URL used by this client.
187    #[must_use]
188    pub const fn base_url(&self) -> &Url {
189        &self.base_url
190    }
191
192    /// Returns the computed `./verify` URL relative to [`FacilitatorClient::base_url`].
193    #[must_use]
194    pub const fn verify_url(&self) -> &Url {
195        &self.verify_url
196    }
197
198    /// Returns the computed `./settle` URL relative to [`FacilitatorClient::base_url`].
199    #[must_use]
200    pub const fn settle_url(&self) -> &Url {
201        &self.settle_url
202    }
203
204    /// Returns the computed `./supported` URL relative to [`FacilitatorClient::base_url`].
205    #[must_use]
206    pub const fn supported_url(&self) -> &Url {
207        &self.supported_url
208    }
209
210    /// Returns any custom headers configured on the client.
211    #[must_use]
212    pub const fn headers(&self) -> &HeaderMap {
213        &self.headers
214    }
215
216    /// Returns the configured timeout, if any.
217    #[must_use]
218    pub const fn timeout(&self) -> Option<&Duration> {
219        self.timeout.as_ref()
220    }
221
222    /// Returns a reference to the supported cache.
223    #[must_use]
224    pub const fn supported_cache(&self) -> &SupportedCache {
225        &self.supported_cache
226    }
227
228    /// Constructs a new [`FacilitatorClient`] from a base URL.
229    ///
230    /// This sets up `./verify`, `./settle`, and `./supported` endpoint URLs relative to the base.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`FacilitatorClientError`] if URL construction fails.
235    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    /// Attaches custom headers to all future requests.
277    #[must_use]
278    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
279        self.headers = headers;
280        self
281    }
282
283    /// Sets a timeout for all future requests.
284    #[must_use]
285    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
286        self.timeout = Some(timeout);
287        self
288    }
289
290    /// Sets the TTL for caching the supported endpoint response.
291    ///
292    /// Default is 10 minutes. Use [`Self::without_supported_cache()`] to disable caching.
293    #[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    /// Disables caching for the supported endpoint.
300    #[must_use]
301    pub fn without_supported_cache(self) -> Self {
302        self.with_supported_cache_ttl(Duration::ZERO)
303    }
304
305    /// Sends a `POST /verify` request to the facilitator.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
310    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    /// Sends a `POST /settle` request to the facilitator.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
323    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    /// Sends a `GET /supported` request to the facilitator.
332    /// This is the inner method that always makes an HTTP request.
333    #[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        // F-047: retry rate-limited (`429 Too Many Requests`) and transient
339        // 5xx responses with exponential backoff. The supported endpoint is
340        // idempotent and cached, so retrying is safe and limits flapping
341        // when a facilitator is briefly overloaded.
342        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    /// Sends a `GET /supported` request to the facilitator.
375    /// Results are cached with a configurable TTL (default: 10 minutes).
376    /// Use `supported_inner()` to bypass the cache.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
381    pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
382        // Try to get from cache
383        if let Some(response) = self.supported_cache.get().await {
384            return Ok(response);
385        }
386
387        // Cache miss - fetch and cache
388        #[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    /// Generic POST helper that handles JSON serialization, error mapping,
398    /// timeout application, and telemetry integration.
399    ///
400    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"POST /verify"`).
401    #[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    /// Generic GET helper that handles error mapping, timeout application,
420    /// and telemetry integration.
421    ///
422    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"GET /supported"`).
423    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    /// Applies headers, timeout, sends the request, and parses the JSON response.
436    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        // F-044: facilitators MAY return structured `VerifyResponse::Invalid`
456        // or `SettleResponse::Failure` bodies on non-2xx HTTP statuses
457        // (e.g. 402, 412 per x402 v2 §HTTP transport error mapping). We
458        // therefore always attempt to parse the body as the expected `R`,
459        // falling back to `HttpStatus` only when the body cannot be parsed.
460        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
524/// Converts a string URL into a `FacilitatorClient`, parsing the URL and calling `try_new`.
525impl TryFrom<&str> for FacilitatorClient {
526    type Error = FacilitatorClientError;
527
528    fn try_from(value: &str) -> Result<Self, Self::Error> {
529        // Normalize: strip trailing slashes and add a single trailing slash
530        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
540/// Converts a String URL into a `FacilitatorClient`.
541impl 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/// Records the outcome of a request on a tracing span, including status and errors.
550#[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/// Records the outcome of a request on a tracing span, including status and errors.
566/// Noop if telemetry feature is off.
567#[cfg(not(feature = "telemetry"))]
568const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
569
570/// Instruments a future with a given tracing span.
571#[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 the supported endpoint
598        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        // First call should hit the network
607        let result1 = client.supported().await.unwrap();
608        assert_eq!(result1.kinds.len(), 1);
609
610        // Second call should use cache (same mock call count)
611        let result2 = client.supported().await.unwrap();
612        assert_eq!(result2.kinds.len(), 1);
613
614        // Both results should be equal
615        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 the supported endpoint
624        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        // Create client with 1ms TTL (essentially no caching)
631        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
632            .unwrap()
633            .with_supported_cache_ttl(Duration::from_millis(1));
634
635        // First call
636        let result1 = client.supported().await.unwrap();
637        assert_eq!(result1.kinds.len(), 1);
638
639        // Wait for cache to expire
640        tokio::time::sleep(Duration::from_millis(10)).await;
641
642        // Second call should hit the network again due to expired cache
643        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 the supported endpoint
653        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        // Create client with caching disabled
660        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
661            .unwrap()
662            .without_supported_cache();
663
664        // Each call should hit the network
665        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 the supported endpoint — expect exactly 1 request
678        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        // Clone the client — clones share the same cache
688        let client2 = client.clone();
689
690        // Populate cache on first client
691        let result1 = client.supported().await.unwrap();
692        assert_eq!(result1.kinds.len(), 1);
693
694        // Clone should hit the shared cache (no extra HTTP request)
695        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 the supported endpoint
706        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        // Populate cache
715        let _ = client.supported().await.unwrap();
716
717        // supported_inner() should always make HTTP request, bypassing cache
718        let result = client.supported_inner().await.unwrap();
719        assert_eq!(result.kinds.len(), 1);
720    }
721}