Skip to main content

nym_http_api_client/
lib.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(deprecated)]
5// silences clippy warning: use of deprecated tuple variant `HttpClientError::GenericRequestFailure`: use another more strongly typed variant - this variant is only left for compatibility reasons - TODO
6
7//! Nym HTTP API Client
8//!
9//! Centralizes and implements the core API client functionality. This crate provides custom,
10//! configurable middleware for a re-usable HTTP client that takes advantage of connection pooling
11//! and other benefits provided by the [`reqwest`] `Client`.
12//!
13//! ## Making GET requests
14//!
15//! Create an HTTP `Client` and use it to make a GET request.
16//!
17//! ```rust
18//! # use url::Url;
19//! # use nym_http_api_client::{ApiClient, NO_PARAMS, HttpClientError};
20//!
21//! # type Err = HttpClientError;
22//! # async fn run() -> Result<(), Err> {
23//! let url: Url = "https://nymvpn.com".parse()?;
24//! let client = nym_http_api_client::Client::new(url, None);
25//!
26//! // Send a get request to the `/v1/status` path with no query parameters.
27//! let resp = client.send_get_request(&["v1", "status"], NO_PARAMS).await?;
28//! let body = resp.text().await?;
29//!
30//! println!("body = {body:?}");
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! ## JSON
36//!
37//! There are also json helper methods that assist in executing requests that send or receive json.
38//! It can take any value that can be serialized into JSON.
39//!
40//! ```rust
41//! # use std::collections::HashMap;
42//! # use std::time::Duration;
43//! use nym_http_api_client::{ApiClient, HttpClientError, NO_PARAMS};
44//!
45//! # use serde::{Serialize, Deserialize};
46//! #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
47//! pub struct ApiHealthResponse {
48//!     pub status: ApiStatus,
49//!     pub uptime: u64,
50//! }
51//!
52//! #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
53//! pub enum ApiStatus {
54//!     Up,
55//! }
56//!
57//! # type Err = HttpClientError;
58//! # async fn run() -> Result<(), Err> {
59//! // This will POST a body of `{"lang":"rust","body":"json"}`
60//! let mut map = HashMap::new();
61//! map.insert("lang", "rust");
62//! map.insert("body", "json");
63//!
64//! // Create a client using the ClientBuilder and set a custom timeout.
65//! let client = nym_http_api_client::Client::builder("https://nymvpn.com")?
66//!     .with_timeout(Duration::from_secs(10))
67//!     .build()?;
68//!
69//! // Send a POST request with our json `map` as the body and attempt to parse the body
70//! // of the response as an ApiHealthResponse from json.
71//! let res: ApiHealthResponse = client.post_json(&["v1", "status"], NO_PARAMS, &map)
72//!     .await?;
73//! # Ok(())
74//! # }
75//! ```
76//!
77//! ## Creating an ApiClient Wrapper
78//!
79//! An example API implementation that relies on this crate for managing the HTTP client.
80//!
81//! ```rust
82//! # use async_trait::async_trait;
83//! use nym_http_api_client::{ApiClient, HttpClientError, NO_PARAMS};
84//!
85//! mod routes {
86//!     pub const API_VERSION: &str = "v1";
87//!     pub const API_STATUS_ROUTES: &str = "api-status";
88//!     pub const HEALTH: &str = "health";
89//! }
90//!
91//! mod responses {
92//!     # use serde::{Serialize, Deserialize};
93//!     #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
94//!     pub struct ApiHealthResponse {
95//!         pub status: ApiStatus,
96//!         pub uptime: u64,
97//!     }
98//!
99//!     #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
100//!     pub enum ApiStatus {
101//!         Up,
102//!     }
103//! }
104//!
105//! mod error {
106//!     # use serde::{Serialize, Deserialize};
107//!     # use core::fmt::{Display, Formatter, Result as FmtResult};
108//!     #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
109//!     pub struct RequestError {
110//!         message: String,
111//!     }
112//!
113//!     impl Display for RequestError {
114//!         fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
115//!             Display::fmt(&self.message, f)
116//!         }
117//!     }
118//! }
119//!
120//! pub type SpecificAPIError = HttpClientError;
121//!
122//! #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
123//! #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
124//! pub trait SpecificApi: ApiClient {
125//!     async fn health(&self) -> Result<responses::ApiHealthResponse, SpecificAPIError> {
126//!         self.get_json(
127//!             &[
128//!                 routes::API_VERSION,
129//!                 routes::API_STATUS_ROUTES,
130//!                 routes::HEALTH,
131//!             ],
132//!             NO_PARAMS,
133//!         )
134//!         .await
135//!     }
136//! }
137//!
138//! impl<T: ApiClient> SpecificApi for T {}
139//! ```
140#![warn(missing_docs)]
141
142use http::header::{RETRY_AFTER, USER_AGENT};
143pub use inventory;
144pub use reqwest::{self, ClientBuilder as ReqwestClientBuilder, StatusCode};
145use std::error::Error;
146use std::sync::Mutex;
147#[cfg(not(target_arch = "wasm32"))]
148use std::time::Instant;
149#[cfg(target_arch = "wasm32")]
150use wasmtimer::std::Instant;
151
152pub mod registry;
153
154// re-exported (not merely `use`d) because it appears as a bound on the public
155// `ApiClientCore::create_request`, so external crates cannot implement that public trait
156// without being able to name it (e.g. to provide a test double).
157pub use crate::path::RequestPath;
158use async_trait::async_trait;
159use bytes::Bytes;
160use cfg_if::cfg_if;
161use http::{
162    HeaderMap,
163    header::{ACCEPT, CONTENT_TYPE},
164};
165use itertools::Itertools;
166use mime::Mime;
167use reqwest::{RequestBuilder, Response, header::HeaderValue};
168use serde::{Deserialize, Serialize, de::DeserializeOwned};
169#[cfg(not(target_arch = "wasm32"))]
170use std::io::ErrorKind;
171use std::{
172    fmt::Display,
173    sync::atomic::{AtomicUsize, Ordering},
174    time::Duration,
175};
176use thiserror::Error;
177use tracing::{debug, instrument, warn};
178
179use std::sync::{Arc, LazyLock};
180
181#[cfg(feature = "tunneling")]
182mod fronted;
183#[cfg(feature = "tunneling")]
184pub use fronted::FrontPolicy;
185mod url;
186pub use url::{IntoUrl, Url};
187mod user_agent;
188pub use user_agent::UserAgent;
189
190#[cfg(not(target_arch = "wasm32"))]
191pub mod dns;
192mod path;
193
194#[cfg(not(target_arch = "wasm32"))]
195pub use dns::{HickoryDnsResolver, ResolveError};
196
197// helper for generating user agent based on binary information
198#[cfg(not(target_arch = "wasm32"))]
199use crate::registry::default_builder;
200#[doc(hidden)]
201pub use nym_bin_common::bin_info;
202#[cfg(not(target_arch = "wasm32"))]
203use nym_http_api_client_macro::client_defaults;
204
205/// Default HTTP request connection timeout.
206///
207/// The timeout is relatively high as we are often making requests over the mixnet, where latency is
208/// high and chatty protocols take a while to complete.
209pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
210
211const NYM_OUTER_SNI_HEADER: &str = "NYM-ORIGINAL-OUTER-SNI";
212
213#[cfg(not(target_arch = "wasm32"))]
214client_defaults!(
215    priority = -100;
216    gzip = true,
217    deflate = true,
218    brotli = true,
219    zstd = true,
220    timeout = DEFAULT_TIMEOUT,
221    user_agent = format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION"))
222);
223
224static SHARED_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
225    tracing::info!("Initializing shared HTTP client");
226    cfg_if! {
227        if #[cfg(target_arch = "wasm32")] {
228            reqwest::ClientBuilder::new().build()
229                .expect("failed to initialize shared http client")
230        } else {
231            let mut builder = default_builder();
232
233            builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::new()));
234
235            builder
236                .build()
237                .expect("failed to initialize shared http client")
238        }
239    }
240});
241
242pub(crate) static SHARED_NETWORK_RECONFIGURATION: LazyLock<Arc<Mutex<Option<Instant>>>> =
243    LazyLock::new(|| Arc::new(Mutex::new(None)));
244
245/// Indicate to the shared marker that a network reconfiguration happened.
246pub fn network_reconfigured() {
247    *SHARED_NETWORK_RECONFIGURATION.lock().unwrap() = Some(Instant::now());
248}
249
250/// Collection of URL Path Segments
251pub type PathSegments<'a> = &'a [&'a str];
252/// Collection of HTTP Request Parameters
253pub type Params<'a, K, V> = &'a [(K, V)];
254
255/// Empty collection of HTTP Request Parameters.
256pub const NO_PARAMS: Params<'_, &'_ str, &'_ str> = &[];
257
258/// Serialization format for API requests and responses
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum SerializationFormat {
261    /// Use JSON serialization (default, always works)
262    Json,
263    /// Use bincode serialization (must be explicitly opted into)
264    Bincode,
265    /// Use YAML serialization
266    Yaml,
267    /// Use Text serialization
268    Text,
269}
270
271impl Display for SerializationFormat {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        match self {
274            SerializationFormat::Json => write!(f, "json"),
275            SerializationFormat::Bincode => write!(f, "bincode"),
276            SerializationFormat::Yaml => write!(f, "yaml"),
277            SerializationFormat::Text => write!(f, "text"),
278        }
279    }
280}
281
282impl SerializationFormat {
283    #[allow(missing_docs)]
284    pub fn content_type(&self) -> String {
285        match self {
286            SerializationFormat::Json => "application/json".to_string(),
287            SerializationFormat::Bincode => "application/bincode".to_string(),
288            SerializationFormat::Yaml => "application/yaml".to_string(),
289            SerializationFormat::Text => "text/plain".to_string(),
290        }
291    }
292}
293
294#[allow(missing_docs)]
295#[derive(Debug)]
296pub struct ReqwestErrorWrapper(reqwest::Error);
297
298impl Display for ReqwestErrorWrapper {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        cfg_if::cfg_if! {
301            if #[cfg(not(target_arch = "wasm32"))] {
302                if self.0.is_connect() {
303                    write!(f, "failed to connect: ")?;
304                }
305            }
306        }
307
308        if self.0.is_timeout() {
309            write!(f, "timed out: ")?;
310        }
311        if self.0.is_redirect()
312            && let Some(final_stop) = self.0.url()
313        {
314            write!(f, "redirect loop at {final_stop}: ")?;
315        }
316
317        self.0.fmt(f)?;
318        if let Some(status_code) = self.0.status() {
319            write!(f, " status: {status_code}")?;
320        } else {
321            write!(f, " unknown status code")?;
322        }
323
324        if let Some(source) = self.0.source() {
325            write!(f, " source: {source:?}")?;
326        } else {
327            write!(f, " unknown lower-level error source")?;
328        }
329
330        Ok(())
331    }
332}
333
334impl std::error::Error for ReqwestErrorWrapper {}
335
336/// The Errors that may occur when creating or using an HTTP client.
337#[derive(Debug, Error)]
338#[allow(missing_docs)]
339pub enum HttpClientError {
340    #[error("did not provide any valid client URLs")]
341    NoUrlsProvided,
342
343    #[error("failed to construct inner reqwest client: {source}")]
344    ReqwestBuildError {
345        #[source]
346        source: reqwest::Error,
347    },
348
349    #[deprecated(
350        note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
351    )]
352    #[error("request failed with error message: {0}")]
353    GenericRequestFailure(String),
354
355    #[deprecated(
356        note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
357    )]
358    #[error("there was an issue with the REST request: {source}")]
359    ReqwestClientError {
360        #[from]
361        source: reqwest::Error,
362    },
363
364    #[error("failed to parse {raw} as a valid URL: {source}")]
365    MalformedUrl {
366        raw: String,
367        #[source]
368        source: reqwest::Error,
369    },
370
371    #[error("failed to parse header value: {source}")]
372    InvalidHeaderValue {
373        #[source]
374        source: http::Error,
375    },
376
377    #[error("failed to send request for {url}: {source}")]
378    RequestSendFailure {
379        url: Box<reqwest::Url>,
380        #[source]
381        source: ReqwestErrorWrapper,
382    },
383
384    #[error("failed to read response body from {url}: {source}")]
385    ResponseReadFailure {
386        url: Box<reqwest::Url>,
387        headers: Box<HeaderMap>,
388        status: StatusCode,
389        #[source]
390        source: ReqwestErrorWrapper,
391    },
392
393    #[error("failed to deserialize received response: {source}")]
394    ResponseDeserialisationFailure { source: serde_json::Error },
395
396    #[error("provided url is malformed: {source}")]
397    UrlParseFailure {
398        #[from]
399        source: url::ParseError,
400    },
401
402    #[error("the requested resource could not be found at {url}")]
403    NotFound { url: Box<reqwest::Url> },
404
405    #[error("attempted to use domain fronting and clone a request containing stream data")]
406    AttemptedToCloneStreamRequest,
407
408    // #[error("request failed with error message: {0}")]
409    // GenericRequestFailure(String),
410    //
411    #[error(
412        "the request for {url} failed with status '{status}'. no additional error message provided. response headers: {headers:?}"
413    )]
414    RequestFailure {
415        url: Box<reqwest::Url>,
416        status: StatusCode,
417        headers: Box<HeaderMap>,
418    },
419
420    #[error(
421        "the returned response from {url} was empty. status: '{status}'. response headers: {headers:?}"
422    )]
423    EmptyResponse {
424        url: Box<reqwest::Url>,
425        status: StatusCode,
426        headers: Box<HeaderMap>,
427    },
428
429    #[error(
430        "failed to resolve request for {url}. status: '{status}'. response headers: {headers:?}. additional error message: {error}"
431    )]
432    EndpointFailure {
433        url: Box<reqwest::Url>,
434        status: StatusCode,
435        headers: Box<HeaderMap>,
436        error: String,
437    },
438
439    #[error("failed to decode response body: {message} from {content}")]
440    ResponseDecodeFailure { message: String, content: String },
441
442    #[error("failed to resolve request to {url} due to data inconsistency: {details}")]
443    InternalResponseInconsistency { url: ::url::Url, details: String },
444
445    #[cfg(not(target_arch = "wasm32"))]
446    #[error("encountered dns failure: {inner}")]
447    DnsLookupFailure {
448        #[from]
449        inner: ResolveError,
450    },
451
452    #[error("Failed to encode bincode: {0}")]
453    Bincode(#[from] bincode::Error),
454
455    #[error("Failed to json: {0}")]
456    Json(#[from] serde_json::Error),
457
458    #[error("Failed to yaml: {0}")]
459    Yaml(#[from] serde_yaml::Error),
460
461    #[error("Failed to plain: {0}")]
462    Plain(#[from] serde_plain::Error),
463
464    #[cfg(target_arch = "wasm32")]
465    #[error("the request has timed out")]
466    RequestTimeout,
467}
468
469#[allow(missing_docs)]
470#[allow(deprecated)]
471impl HttpClientError {
472    /// Returns true if the error is a timeout.
473    pub fn is_timeout(&self) -> bool {
474        match self {
475            HttpClientError::ReqwestClientError { source } => source.is_timeout(),
476            HttpClientError::RequestSendFailure { source, .. } => source.0.is_timeout(),
477            HttpClientError::ResponseReadFailure { source, .. } => source.0.is_timeout(),
478            #[cfg(not(target_arch = "wasm32"))]
479            HttpClientError::DnsLookupFailure { inner } => inner.is_timeout(),
480            #[cfg(target_arch = "wasm32")]
481            HttpClientError::RequestTimeout => true,
482            _ => false,
483        }
484    }
485
486    /// Returns the HTTP status code if available.
487    pub fn status_code(&self) -> Option<StatusCode> {
488        match self {
489            HttpClientError::ResponseReadFailure { status, .. } => Some(*status),
490            HttpClientError::RequestFailure { status, .. } => Some(*status),
491            HttpClientError::EmptyResponse { status, .. } => Some(*status),
492            HttpClientError::EndpointFailure { status, .. } => Some(*status),
493            _ => None,
494        }
495    }
496
497    pub fn reqwest_client_build_error(source: reqwest::Error) -> Self {
498        HttpClientError::ReqwestBuildError { source }
499    }
500
501    pub fn request_send_error(url: reqwest::Url, source: reqwest::Error) -> Self {
502        HttpClientError::RequestSendFailure {
503            url: Box::new(url),
504            source: ReqwestErrorWrapper(source),
505        }
506    }
507
508    pub fn is_data_inconsistency(&self) -> bool {
509        matches!(self, HttpClientError::InternalResponseInconsistency { .. })
510    }
511}
512
513/// Core functionality required for types acting as API clients.
514///
515/// This trait defines the "skinny waist" of behaviors that are required by an API client. More
516/// likely downstream libraries should use functions from the [`ApiClient`] interface which provide
517/// a more ergonomic set of functionalities.
518#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
519#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
520pub trait ApiClientCore {
521    /// Create an HTTP request using the host configured in this client.
522    fn create_request<P, B, K, V>(
523        &self,
524        method: reqwest::Method,
525        path: P,
526        params: Params<'_, K, V>,
527        body: Option<&B>,
528    ) -> Result<RequestBuilder, HttpClientError>
529    where
530        P: RequestPath,
531        B: Serialize + ?Sized,
532        K: AsRef<str>,
533        V: AsRef<str>;
534
535    /// Create an HTTP request using the host configured in this client and an API endpoint (i.e.
536    /// `"/api/v1/mixnodes?since=12345"`). If the provided endpoint fails to parse as path (and
537    /// optionally query parameters).
538    ///
539    /// Endpoint Examples
540    /// - `"/api/v1/mixnodes?since=12345"`
541    /// - `"/api/v1/mixnodes"`
542    /// - `"/api/v1/mixnodes/img.png"`
543    /// - `"/api/v1/mixnodes/img.png?since=12345"`
544    /// - `"/"`
545    /// - `"/?since=12345"`
546    /// - `""`
547    /// - `"?since=12345"`
548    ///
549    /// for more information about URL percent encodings see [`url::Url::set_path()`]
550    fn create_request_endpoint<B, S>(
551        &self,
552        method: reqwest::Method,
553        endpoint: S,
554        body: Option<&B>,
555    ) -> Result<RequestBuilder, HttpClientError>
556    where
557        B: Serialize + ?Sized,
558        S: AsRef<str>,
559    {
560        // Use a stand-in url to extract the path and queries from the provided endpoint string
561        // which could potentially fail.
562        //
563        // This parse cannot fail
564        let mut standin_url: Url = "http://example.com".parse().unwrap();
565
566        match endpoint.as_ref().split_once("?") {
567            Some((path, query)) => {
568                standin_url.set_path(path);
569                standin_url.set_query(Some(query));
570            }
571            // There is no query in the provided endpoint
572            None => standin_url.set_path(endpoint.as_ref()),
573        }
574
575        let path: Vec<&str> = match standin_url.path_segments() {
576            Some(segments) => segments.collect(),
577            None => Vec::new(),
578        };
579        let params: Vec<(String, String)> = standin_url.query_pairs().into_owned().collect();
580
581        self.create_request(method, path.as_slice(), &params, body)
582    }
583
584    /// Send a created HTTP request.
585    ///
586    /// A [`RequestBuilder`] can be created with [`ApiClientCore::create_request`] or
587    /// [`ApiClientCore::create_request_endpoint`] or if absolutely necessary, using reqwest
588    /// tooling directly.
589    async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError>;
590
591    /// Create and send a created HTTP request.
592    async fn send_request<P, B, K, V>(
593        &self,
594        method: reqwest::Method,
595        path: P,
596        params: Params<'_, K, V>,
597        json_body: Option<&B>,
598    ) -> Result<Response, HttpClientError>
599    where
600        P: RequestPath + Send + Sync,
601        B: Serialize + ?Sized + Sync,
602        K: AsRef<str> + Sync,
603        V: AsRef<str> + Sync,
604    {
605        let req = self.create_request(method, path, params, json_body)?;
606        self.send(req).await
607    }
608
609    /// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
610    ///
611    /// Takes an optional URL argument. If this is none, the current host will be updated automatically.
612    /// If a url is provided first check that the CURRENT host matches the hostname in the URL before
613    /// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host
614    /// multiple times.
615    fn maybe_rotate_hosts(&self, offending_url: Option<Url>);
616
617    /// If the fronting policy for the client is set to `OnRetry` this function will enable the
618    /// fronting if not already enabled.
619    #[cfg(feature = "tunneling")]
620    fn maybe_enable_fronting(&self, context: impl std::fmt::Debug);
621}
622
623/// A `ClientBuilder` can be used to create a [`Client`] with custom configuration applied consistently
624/// and state tracked across subsequent requests.
625pub struct ClientBuilder {
626    urls: Vec<Url>,
627
628    timeout: Option<Duration>,
629    custom_user_agent: Option<HeaderValue>,
630    reqwest_client_builder: Option<reqwest::ClientBuilder>,
631    #[allow(dead_code)] // not dead code, just unused in wasm
632    use_secure_dns: bool,
633
634    #[cfg(feature = "tunneling")]
635    front: fronted::Front,
636
637    retry_limit: usize,
638    serialization: SerializationFormat,
639
640    error: Option<HttpClientError>,
641}
642
643impl ClientBuilder {
644    /// Constructs a new `ClientBuilder`.
645    ///
646    /// This is the same as `Client::builder()`.
647    pub fn new<U>(url: U) -> Result<Self, HttpClientError>
648    where
649        U: IntoUrl,
650    {
651        let str_url = url.as_str();
652
653        // a naive check: if the provided URL does not start with http(s), add that scheme
654        if !str_url.starts_with("http") {
655            let alt = format!("http://{str_url}");
656            warn!(
657                "the provided url ('{str_url}') does not contain scheme information. Changing it to '{alt}' ..."
658            );
659            // TODO: or should we maybe default to https?
660            Self::new(alt)
661        } else {
662            let url = url.to_url()?;
663            Self::new_with_urls(vec![url])
664        }
665    }
666
667    /// Create a client builder from network details with sensible defaults
668    #[cfg(feature = "network-defaults")]
669    // deprecating function since it's not clear from its signature whether the client
670    // would be constructed using `nym_api_urls` or `nym_vpn_api_urls`
671    #[deprecated(note = "use explicit Self::new_with_fronted_urls instead")]
672    pub fn from_network(
673        network: &nym_network_defaults::NymNetworkDetails,
674    ) -> Result<Self, HttpClientError> {
675        let urls = network.nym_api_urls();
676        Self::new_with_fronted_urls(urls.clone())
677    }
678
679    /// Create a client builder using the provided set of domain-fronted URLs
680    #[cfg(feature = "network-defaults")]
681    pub fn new_with_fronted_urls(
682        urls: Vec<nym_network_defaults::ApiUrl>,
683    ) -> Result<Self, HttpClientError> {
684        let urls = urls
685            .into_iter()
686            .map(|api_url| {
687                // Convert ApiUrl to our Url type with fronting support
688                let mut url = Url::parse(&api_url.url)?;
689
690                // Add fronting domains if available
691                #[cfg(feature = "tunneling")]
692                if let Some(ref front_hosts) = api_url.front_hosts {
693                    let fronts: Vec<String> = front_hosts
694                        .iter()
695                        .map(|host| format!("https://{}", host))
696                        .collect();
697                    url = Url::new(api_url.url.clone(), Some(fronts)).map_err(|source| {
698                        HttpClientError::MalformedUrl {
699                            raw: api_url.url.clone(),
700                            source,
701                        }
702                    })?;
703                }
704
705                Ok(url)
706            })
707            .collect::<Result<Vec<_>, HttpClientError>>()?;
708
709        let mut builder = Self::new_with_urls(urls)?;
710
711        // Enable domain fronting using the shared fronting policy
712        #[cfg(feature = "tunneling")]
713        {
714            builder = builder.with_fronting(None);
715        }
716
717        Ok(builder)
718    }
719
720    /// Constructs a new http `ClientBuilder` from a valid url.
721    pub fn new_with_urls(urls: Vec<Url>) -> Result<Self, HttpClientError> {
722        if urls.is_empty() {
723            return Err(HttpClientError::NoUrlsProvided);
724        }
725
726        let urls = Self::check_urls(urls);
727
728        Ok(ClientBuilder {
729            urls,
730            timeout: None,
731            custom_user_agent: None,
732            reqwest_client_builder: None,
733            use_secure_dns: true,
734            #[cfg(feature = "tunneling")]
735            front: fronted::Front::off(),
736
737            retry_limit: 0,
738            serialization: SerializationFormat::Json,
739            error: None,
740        })
741    }
742
743    /// Configure use of an independent HTTP request executor. This prevents use of beneficial
744    /// features like connection pooling under the hood.
745    #[cfg(not(target_arch = "wasm32"))]
746    pub fn non_shared(mut self) -> Self {
747        if self.reqwest_client_builder.is_none() {
748            self.reqwest_client_builder = Some(default_builder());
749        }
750        self
751    }
752
753    /// Add an additional URL to the set usable by this constructed `Client`
754    pub fn add_url(mut self, url: Url) -> Self {
755        self.urls.push(url);
756        self
757    }
758
759    fn check_urls(mut urls: Vec<Url>) -> Vec<Url> {
760        // remove any duplicate URLs
761        urls = urls.into_iter().unique().collect();
762
763        // warn about any invalid URLs
764        urls.iter()
765            .filter(|url| !url.scheme().contains("http") && !url.scheme().contains("https"))
766            .for_each(|url| {
767                warn!("the provided url ('{url}') does not use HTTP / HTTPS scheme");
768            });
769
770        urls
771    }
772
773    /// Enables a total request timeout other than the default.
774    ///
775    /// The timeout is applied from when the request starts connecting until the response body has finished. Also considered a total deadline.
776    ///
777    /// Default is [`DEFAULT_TIMEOUT`].
778    #[must_use]
779    pub fn with_timeout(mut self, timeout: Duration) -> Self {
780        self.timeout = Some(timeout);
781        self
782    }
783
784    /// Sets the maximum number of retries for a request. This defaults to 0, indicating no retries.
785    ///
786    /// Note that setting a retry limit of 3 (for example) will result in 4 attempts to send the
787    /// request in the case that all are unsuccessful.
788    ///
789    /// If multiple urls (or fronting configurations if enabled) are available, retried requests
790    /// will be sent to the next URL in the list.
791    #[must_use]
792    pub fn with_retries(mut self, retry_limit: usize) -> Self {
793        self.retry_limit = retry_limit;
794        self
795    }
796
797    /// Provide a pre-configured [`reqwest::ClientBuilder`]
798    #[must_use]
799    pub fn with_reqwest_builder(mut self, reqwest_builder: reqwest::ClientBuilder) -> Self {
800        self.reqwest_client_builder = Some(reqwest_builder);
801        self
802    }
803
804    /// Sets the `User-Agent` header to be used by this client.
805    #[must_use]
806    pub fn with_user_agent<V>(mut self, value: V) -> Self
807    where
808        V: TryInto<HeaderValue>,
809        V::Error: Into<http::Error>,
810    {
811        match value.try_into() {
812            Ok(v) => self.custom_user_agent = Some(v),
813            Err(err) => {
814                self.error = Some(HttpClientError::InvalidHeaderValue { source: err.into() })
815            }
816        }
817        self
818    }
819
820    /// Set the serialization format for API requests and responses
821    #[must_use]
822    pub fn with_serialization(mut self, format: SerializationFormat) -> Self {
823        self.serialization = format;
824        self
825    }
826
827    /// Configure the client to use bincode serialization
828    #[must_use]
829    pub fn with_bincode(self) -> Self {
830        self.with_serialization(SerializationFormat::Bincode)
831    }
832
833    /// Returns a Client that uses this ClientBuilder configuration.
834    pub fn build(self) -> Result<Client, HttpClientError> {
835        if let Some(err) = self.error {
836            return Err(err);
837        }
838
839        #[cfg(target_arch = "wasm32")]
840        let reqwest_client = Some(reqwest::ClientBuilder::new().build()?);
841
842        #[cfg(not(target_arch = "wasm32"))]
843        let reqwest_client = self
844            .reqwest_client_builder
845            .map(|mut builder| {
846                // unless explicitly disabled use the DoT/DoH enabled resolver
847                if self.use_secure_dns {
848                    builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::new()));
849                }
850
851                builder
852                    .build()
853                    .map_err(HttpClientError::reqwest_client_build_error)
854            })
855            .transpose()?;
856
857        let client = Client {
858            base_urls: self.urls,
859            current_idx: Arc::new(AtomicUsize::new(0)),
860            reqwest_client,
861            custom_user_agent: self.custom_user_agent,
862
863            #[cfg(feature = "tunneling")]
864            front: self.front,
865
866            #[cfg(target_arch = "wasm32")]
867            request_timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
868            retry_limit: self.retry_limit,
869            serialization: self.serialization,
870        };
871
872        Ok(client)
873    }
874}
875
876/// A simple extendable client wrapper for http request with extra url sanitization.
877#[derive(Debug, Clone)]
878pub struct Client {
879    base_urls: Vec<Url>,
880    current_idx: Arc<AtomicUsize>,
881    reqwest_client: Option<reqwest::Client>,
882    custom_user_agent: Option<HeaderValue>,
883
884    #[cfg(feature = "tunneling")]
885    front: fronted::Front,
886
887    #[cfg(target_arch = "wasm32")]
888    request_timeout: Duration,
889
890    retry_limit: usize,
891    serialization: SerializationFormat,
892}
893
894impl Client {
895    /// Create a new http `Client`
896    // no timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed
897    //
898    // In order to prevent interference in API requests at the DNS phase we default to a resolver
899    // that uses DoT and DoH.
900    pub fn new(base_url: ::url::Url, timeout: Option<Duration>) -> Self {
901        Self::new_url(base_url, timeout).expect(
902            "we provided valid url and we were unwrapping previous construction errors anyway",
903        )
904    }
905
906    /// Attempt to create a new http client from a something that can be converted to a URL
907    pub fn new_url<U>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError>
908    where
909        U: IntoUrl,
910    {
911        let builder = Self::builder(url)?;
912        match timeout {
913            Some(timeout) => builder.with_timeout(timeout).build(),
914            None => builder.build(),
915        }
916    }
917
918    /// Creates a [`ClientBuilder`] to configure a [`Client`].
919    ///
920    /// This is the same as [`ClientBuilder::new()`].
921    pub fn builder<U>(url: U) -> Result<ClientBuilder, HttpClientError>
922    where
923        U: IntoUrl,
924    {
925        ClientBuilder::new(url)
926    }
927
928    /// Update the set of hosts that this client uses when sending API requests.
929    pub fn change_base_urls(&mut self, new_urls: Vec<Url>) {
930        self.current_idx.store(0, Ordering::Relaxed);
931        self.base_urls = new_urls
932    }
933
934    /// Create new instance of `Client` using the provided base url and existing client config
935    pub fn clone_with_new_url(&self, new_url: Url) -> Self {
936        Client {
937            base_urls: vec![new_url],
938            current_idx: Arc::new(Default::default()),
939            reqwest_client: None,
940            custom_user_agent: None,
941
942            #[cfg(feature = "tunneling")]
943            front: self.front.clone(),
944            retry_limit: self.retry_limit,
945
946            #[cfg(target_arch = "wasm32")]
947            request_timeout: self.request_timeout,
948            serialization: self.serialization,
949        }
950    }
951
952    /// Get the currently configured host that this client uses when sending API requests.
953    pub fn current_url(&self) -> &Url {
954        &self.base_urls[self.current_idx.load(std::sync::atomic::Ordering::Relaxed)]
955    }
956
957    /// Get the currently configured host that this client uses when sending API requests.
958    pub fn base_urls(&self) -> &[Url] {
959        &self.base_urls
960    }
961
962    /// Get a mutable reference to the hosts that this client uses when sending API requests.
963    pub fn base_urls_mut(&mut self) -> &mut [Url] {
964        &mut self.base_urls
965    }
966
967    /// Change the currently configured limit on the number of retries for a request.
968    pub fn change_retry_limit(&mut self, limit: usize) {
969        self.retry_limit = limit;
970    }
971
972    #[cfg(feature = "tunneling")]
973    fn matches_current_host(&self, url: &Url) -> bool {
974        // Only compare against the front host if the current url actually has one configured -
975        // otherwise requests to it go out unfronted, so the offending host will be the real one.
976        if self.front.is_enabled() && self.current_url().has_front() {
977            url.host_str() == self.current_url().front_str()
978        } else {
979            url.host_str() == self.current_url().host_str()
980        }
981    }
982
983    #[cfg(not(feature = "tunneling"))]
984    fn matches_current_host(&self, url: &Url) -> bool {
985        url.host_str() == self.current_url().host_str()
986    }
987
988    /// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
989    ///
990    /// Takes an optional URL argument. If this is none, the current host will be updated automatically.
991    /// If a url is provided first check that the CURRENT host matches the hostname in the URL before
992    /// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host
993    /// multiple times.
994    fn update_host(&self, maybe_url: Option<Url>) {
995        // If a causal url is provided and it doesn't match the hostname currently in use, skip update.
996        if let Some(err_url) = maybe_url
997            && !self.matches_current_host(&err_url)
998        {
999            return;
1000        }
1001
1002        #[cfg(feature = "tunneling")]
1003        if self.front.is_enabled() {
1004            // if we are using fronting, try updating to the next front
1005            let url = self.current_url();
1006
1007            // try to update the current host to use a next front, if one is available, otherwise
1008            // we move on and try the next base url (if one is available)
1009            if url.has_front() && !url.update() {
1010                // we swapped to the next front for the current host
1011                return;
1012            }
1013        }
1014
1015        if self.base_urls.len() > 1 {
1016            let orig = self.current_idx.load(Ordering::Relaxed);
1017
1018            #[allow(unused_mut)]
1019            let mut next = (orig + 1) % self.base_urls.len();
1020
1021            // if fronting is enabled we want to update to a host that has fronts configured
1022            #[cfg(feature = "tunneling")]
1023            if self.front.is_enabled() {
1024                while next != orig {
1025                    if self.base_urls[next].has_front() {
1026                        // we have a front for the next host, so we can use it
1027                        break;
1028                    }
1029
1030                    next = (next + 1) % self.base_urls.len();
1031                }
1032            }
1033
1034            self.current_idx.store(next, Ordering::Relaxed);
1035            debug!(
1036                "http client rotating host {} -> {}",
1037                self.base_urls[orig], self.base_urls[next]
1038            );
1039        }
1040    }
1041
1042    /// Make modifications to the request to apply the current state of this client i.e. the
1043    /// currently configured host. This is required as a caller may use this client to create a
1044    /// request, but then have the state of the client change before the caller uses the client to
1045    /// send their request.
1046    ///
1047    /// This enures that the outgoing requests benefit from the configured fallback mechanisms, even
1048    /// for requests that were created before the state of the client changed.
1049    ///
1050    /// This method assumes that any updates to the state of the client are made before the call to
1051    /// this method. For example, if the client is configured to rotate hosts after each error, this
1052    /// method should be called after the host has been updated -- i.e. as part of the subsequent
1053    /// send.
1054    pub(crate) fn apply_hosts_to_req(&self, r: &mut reqwest::Request) -> (&str, Option<&str>) {
1055        let url = self.current_url();
1056        r.url_mut().set_host(url.host_str()).unwrap();
1057
1058        #[cfg(feature = "tunneling")]
1059        if self.front.is_enabled() {
1060            if let Some(front_host) = url.front_str() {
1061                if let Some(actual_host) = url.host_str() {
1062                    tracing::debug!(
1063                        "Domain fronting enabled: routing via CDN {} to actual host {}",
1064                        front_host,
1065                        actual_host
1066                    );
1067
1068                    // this should never fail as we are transplanting the host from one url to another
1069                    r.url_mut().set_host(Some(front_host)).unwrap();
1070
1071                    let actual_host_header: HeaderValue =
1072                        actual_host.parse().unwrap_or(HeaderValue::from_static(""));
1073                    // If the map did have this key present, the new value is associated with the key
1074                    // and all previous values are removed. (reqwest HeaderMap docs)
1075                    _ = r
1076                        .headers_mut()
1077                        .insert(reqwest::header::HOST, actual_host_header);
1078
1079                    // Set a custom header to capture the outer host (used in the SNI) of the request
1080                    let front_host_header: HeaderValue =
1081                        front_host.parse().unwrap_or(HeaderValue::from_static(""));
1082                    _ = r
1083                        .headers_mut()
1084                        .insert(NYM_OUTER_SNI_HEADER, front_host_header);
1085
1086                    return (url.as_str(), url.front_str());
1087                } else {
1088                    tracing::debug!(
1089                        "Domain fronting is enabled, but no host_url is defined for current URL"
1090                    )
1091                }
1092            } else {
1093                tracing::debug!(
1094                    "Domain fronting is enabled, but current URL has no front_hosts configured"
1095                )
1096            }
1097        }
1098        (url.as_str(), None)
1099    }
1100}
1101
1102#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1103#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1104impl ApiClientCore for Client {
1105    #[instrument(level = "debug", skip_all, fields(path=?path))]
1106    fn create_request<P, B, K, V>(
1107        &self,
1108        method: reqwest::Method,
1109        path: P,
1110        params: Params<'_, K, V>,
1111        body: Option<&B>,
1112    ) -> Result<RequestBuilder, HttpClientError>
1113    where
1114        P: RequestPath,
1115        B: Serialize + ?Sized,
1116        K: AsRef<str>,
1117        V: AsRef<str>,
1118    {
1119        let url = self.current_url();
1120        let url = sanitize_url(url, path, params);
1121
1122        let mut req = reqwest::Request::new(method, url.into());
1123
1124        self.apply_hosts_to_req(&mut req);
1125
1126        let client = if let Some(client) = &self.reqwest_client {
1127            client.clone()
1128        } else {
1129            SHARED_CLIENT.clone()
1130        };
1131        let mut rb = RequestBuilder::from_parts(client, req);
1132
1133        rb = rb
1134            .header(ACCEPT, self.serialization.content_type())
1135            .header(CONTENT_TYPE, self.serialization.content_type());
1136
1137        if let Some(user_agent) = &self.custom_user_agent {
1138            rb = rb.header(USER_AGENT, user_agent.clone());
1139        }
1140
1141        if let Some(body) = body {
1142            match self.serialization {
1143                SerializationFormat::Json => {
1144                    rb = rb.json(body);
1145                }
1146                SerializationFormat::Bincode => {
1147                    let body = bincode::serialize(body)?;
1148                    rb = rb.body(body);
1149                }
1150                SerializationFormat::Yaml => {
1151                    let mut body_bytes = Vec::new();
1152                    serde_yaml::to_writer(&mut body_bytes, &body)?;
1153                    rb = rb.body(body_bytes);
1154                }
1155                SerializationFormat::Text => {
1156                    let body = serde_plain::to_string(&body)?.as_bytes().to_vec();
1157                    rb = rb.body(body);
1158                }
1159            }
1160        }
1161
1162        Ok(rb)
1163    }
1164
1165    async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError> {
1166        let mut attempts = 0;
1167        loop {
1168            // try_clone may fail if the body is a stream in which case using retries is not advised.
1169            let r = request
1170                .try_clone()
1171                .ok_or(HttpClientError::AttemptedToCloneStreamRequest)?;
1172
1173            // apply any changes based on the current state of the client wrt. hosts,
1174            // fronting domains, etc.
1175            let mut req = r
1176                .build()
1177                .map_err(HttpClientError::reqwest_client_build_error)?;
1178            self.apply_hosts_to_req(&mut req);
1179            let url: Url = req.url().clone().into();
1180
1181            let request_start = Instant::now();
1182
1183            #[cfg(target_arch = "wasm32")]
1184            let response: Result<Response, HttpClientError> = {
1185                let client = self
1186                    .reqwest_client
1187                    .as_ref()
1188                    .unwrap_or_else(|| &*SHARED_CLIENT);
1189                Ok(
1190                    wasmtimer::tokio::timeout(self.request_timeout, client.execute(req))
1191                        .await
1192                        .map_err(|_timeout| HttpClientError::RequestTimeout)??,
1193                )
1194            };
1195
1196            #[cfg(not(target_arch = "wasm32"))]
1197            let response = {
1198                let client = self
1199                    .reqwest_client
1200                    .as_ref()
1201                    .unwrap_or_else(|| &*SHARED_CLIENT);
1202                client.execute(req).await
1203            };
1204
1205            match response {
1206                Ok(resp) => {
1207                    // Check if the response indicates that we are being rate limited
1208                    if is_http_rate_limit_err(&resp) {
1209                        warn!("encountered rate limit error for {}", url.as_str());
1210                        // if we have multiple urls, update to the next
1211                        self.maybe_rotate_hosts(Some(url.clone()));
1212                    }
1213
1214                    return Ok(resp);
1215                }
1216                Err(err) => {
1217                    let last_network_reconfiguration =
1218                        *SHARED_NETWORK_RECONFIGURATION.lock().unwrap();
1219                    let network_reconfigured =
1220                        last_network_reconfiguration.is_some_and(|last| last > request_start);
1221
1222                    #[cfg(target_arch = "wasm32")]
1223                    let is_network_err = err.is_timeout();
1224                    #[cfg(not(target_arch = "wasm32"))]
1225                    let is_network_err = might_be_network_interference(&err);
1226
1227                    if is_network_err & !network_reconfigured {
1228                        // if we have multiple urls, update to the next
1229                        self.maybe_rotate_hosts(Some(url.clone()));
1230
1231                        #[cfg(feature = "tunneling")]
1232                        self.maybe_enable_fronting(("network", url.as_str(), &err));
1233                    }
1234
1235                    if attempts < self.retry_limit {
1236                        attempts += 1;
1237                        warn!(
1238                            "Retrying request due to http error on attempt ({attempts}/{}): {err}",
1239                            self.retry_limit
1240                        );
1241                        continue;
1242                    }
1243
1244                    // if we have exhausted our attempts, return the error
1245                    cfg_if::cfg_if! {
1246                        if #[cfg(target_arch = "wasm32")] {
1247                            return Err(err);
1248                        } else {
1249                            return Err(HttpClientError::request_send_error(url.into(), err));
1250                        }
1251                    }
1252                }
1253            }
1254        }
1255    }
1256
1257    fn maybe_rotate_hosts(&self, offending: Option<Url>) {
1258        self.update_host(offending);
1259    }
1260
1261    #[cfg(feature = "tunneling")]
1262    fn maybe_enable_fronting(&self, context: impl std::fmt::Debug) {
1263        // If fronting is set to be OnRetry, enable domain fronting as we
1264        // have encountered an error.
1265        let was_enabled = self.front.is_enabled();
1266        self.front.retry_enable();
1267        if !was_enabled && self.front.is_enabled() {
1268            tracing::debug!("Domain fronting activated after failure: {context:?}",);
1269        }
1270    }
1271}
1272
1273/// Check for a rate limit response: a plain HTTP 429, a 503 with a `Retry-After` header, or a
1274/// rate limit challenge response from the vercel API.
1275pub(crate) fn is_http_rate_limit_err(resp: &Response) -> bool {
1276    is_rate_limit_response(resp.status(), resp.headers())
1277}
1278
1279fn is_rate_limit_response(status: StatusCode, headers: &HeaderMap) -> bool {
1280    let too_many_reqs = status == StatusCode::TOO_MANY_REQUESTS;
1281    let rate_limit_503 = is_throttled_service_unavailable(status, headers);
1282    let vercel_backoff = is_vercel_rate_limit_challenge(status, headers);
1283
1284    too_many_reqs || rate_limit_503 || vercel_backoff
1285}
1286
1287/// A 503 accompanied by `Retry-After` indicates the server is asking us to back off, as opposed
1288/// to a plain 503 which may just mean the service is down.
1289// One of the hosts that we use implements rate-limiting by sending responses with 429 status-code
1290// initially, but then uses 503 with a `Retry-After` header. Using this check allows us to pivot
1291// away from those API endpoints and hopefully escape the rate-limit related error state.
1292//
1293// This is not meant to handle the `Retry-After` and is only checked to determine if we might
1294// benefit from rotating hosts (if there is more than one available).
1295fn is_throttled_service_unavailable(status: StatusCode, headers: &HeaderMap) -> bool {
1296    status == StatusCode::SERVICE_UNAVAILABLE && headers.contains_key(RETRY_AFTER)
1297}
1298
1299const VERCEL_CHALLENGE_HEADER: &str = "x-vercel-mitigated";
1300const VERCEL_CHALLENGE_VALUE: &[u8] = b"challenge";
1301
1302/// Check for a Rate Limit challenge response from the vercel API
1303fn is_vercel_rate_limit_challenge(status: StatusCode, headers: &HeaderMap) -> bool {
1304    let status = status == StatusCode::FORBIDDEN;
1305    let header = headers
1306        .get(VERCEL_CHALLENGE_HEADER)
1307        .is_some_and(|v| v.as_bytes() == VERCEL_CHALLENGE_VALUE);
1308    let content_type = headers
1309        .get(CONTENT_TYPE)
1310        .and_then(|value| value.to_str().ok())
1311        .and_then(|value| value.parse::<Mime>().ok())
1312        .is_some_and(|mime_type| {
1313            mime_type.type_() == mime::TEXT && mime_type.subtype() == mime::HTML
1314        });
1315
1316    status && header && content_type
1317}
1318
1319#[cfg(not(target_arch = "wasm32"))]
1320const MAX_ERR_SOURCE_ITERATIONS: usize = 6;
1321
1322/// This functions attempts to check the error returned by reqwest to see if rotating host
1323/// information (for clients with multiple hosts defined) could be helpful. This looks for
1324/// situations where the error could plausibly be caused by a network adversary, or where rotating
1325/// to an equivalent hostname might help.
1326///
1327/// For example --> NetworkUnreachable will not be helped by rotating domains, but ConnectionReset
1328/// might be caused by a network adversary blocking by SNI which could possibly benefit from
1329/// rotating domains.
1330#[cfg(not(target_arch = "wasm32"))]
1331pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool {
1332    if err.is_timeout() {
1333        return true;
1334    }
1335
1336    if !(err.is_connect() || err.is_request()) {
1337        return false;
1338    }
1339
1340    // The io::Error source is several layers deep, for clarity this is done as a loop
1341    // * reqwest::Error -> hyper_util::Error
1342    // * hyper_util::Error -> hyper_util::ClientError
1343    // * hyper_util::ClientError -> io::Error
1344    let mut inner = err.source();
1345    for _ in 0..MAX_ERR_SOURCE_ITERATIONS {
1346        if let Some(e) = inner {
1347            if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
1348                // try downcast to io::Error from <dyn std::error:Error>
1349                match io_err.kind() {
1350                    // device not connected to the internet
1351                    ErrorKind::NetworkUnreachable | ErrorKind::NetworkDown => return false,
1352                    // connection errors can indicate connection interference
1353                    ErrorKind::ConnectionReset
1354                    | ErrorKind::HostUnreachable
1355                    | ErrorKind::ConnectionRefused => return true,
1356                    // TLS errors get wrapped in custom io::Errors
1357                    ErrorKind::Other | ErrorKind::InvalidData => {
1358                        // io::Error get_ref works while source doesn't here -_-
1359                        //   if you don't like it take it up with the rust devs https://users.rust-lang.org/t/question-about-implementation-of-std-source/121117
1360                        inner = io_err.get_ref().map(|e| e as &dyn std::error::Error);
1361                    }
1362                    _ => return false,
1363                }
1364            } else if let Some(_tls_err) = e.downcast_ref::<rustls::Error>() {
1365                // try downcast to TLS error
1366                return true;
1367            } else if let Some(resolve_err) = e.downcast_ref::<hickory_resolver::net::NetError>() {
1368                // try downcast to DNS error. NXDOMAIN means the domain doesn't exist, SERVFAIL
1369                // indicates a recurive lookup failure and is likely ephemeral but given that it is
1370                // a failure within (expected reliable DoH/DoT) we rotate the domain.
1371                return resolve_err.is_nx_domain()
1372                    || matches!(
1373                        resolve_err,
1374                        hickory_resolver::net::NetError::Dns(
1375                            hickory_resolver::net::DnsError::ResponseCode(
1376                                hickory_resolver::proto::op::ResponseCode::ServFail
1377                            )
1378                        )
1379                    );
1380            } else if let Some(h2_err) = e.downcast_ref::<h2::Error>() {
1381                // try downcast to a h2 (HTTP/2) error. hyper only wraps these as io::Error
1382                // when they are actually backed by one (see `hyper::Error::new_h2`), so if we
1383                // get here it's a protocol-level RST_STREAM or GOAWAY. TLS integrity protection
1384                // means this can only have been sent by whichever party actually terminates the
1385                // TLS connection - for a fronted request that's the front's CDN edge. So it
1386                // indicates that host/front may have actively rejected the connection (e.g.
1387                // detected fronting, rate limiting, or its own backend failing). This has enough
1388                // potentially to continue breaking things that we should rotate hosts if we can.
1389                return h2_err.is_remote() && (h2_err.is_reset() || h2_err.is_go_away());
1390            } else {
1391                inner = e.source();
1392            }
1393        } else {
1394            break;
1395        }
1396    }
1397
1398    false
1399}
1400
1401/// Common usage functionality for the http client.
1402///
1403/// These functions allow for cleaner downstream usage free of type parameters and unneeded imports.
1404#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1405#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1406pub trait ApiClient: ApiClientCore {
1407    /// Create an HTTP GET Request with the provided path and parameters
1408    fn create_get_request<P, K, V>(
1409        &self,
1410        path: P,
1411        params: Params<'_, K, V>,
1412    ) -> Result<RequestBuilder, HttpClientError>
1413    where
1414        P: RequestPath,
1415        K: AsRef<str>,
1416        V: AsRef<str>,
1417    {
1418        self.create_request(reqwest::Method::GET, path, params, None::<&()>)
1419    }
1420
1421    /// Create an HTTP POST Request with the provided path, parameters, and json body
1422    fn create_post_request<P, B, K, V>(
1423        &self,
1424        path: P,
1425        params: Params<'_, K, V>,
1426        json_body: &B,
1427    ) -> Result<RequestBuilder, HttpClientError>
1428    where
1429        P: RequestPath,
1430        B: Serialize + ?Sized,
1431        K: AsRef<str>,
1432        V: AsRef<str>,
1433    {
1434        self.create_request(reqwest::Method::POST, path, params, Some(json_body))
1435    }
1436
1437    /// Create an HTTP DELETE Request with the provided path and parameters
1438    fn create_delete_request<P, K, V>(
1439        &self,
1440        path: P,
1441        params: Params<'_, K, V>,
1442    ) -> Result<RequestBuilder, HttpClientError>
1443    where
1444        P: RequestPath,
1445        K: AsRef<str>,
1446        V: AsRef<str>,
1447    {
1448        self.create_request(reqwest::Method::DELETE, path, params, None::<&()>)
1449    }
1450
1451    /// Create an HTTP PATCH Request with the provided path, parameters, and json body
1452    fn create_patch_request<P, B, K, V>(
1453        &self,
1454        path: P,
1455        params: Params<'_, K, V>,
1456        json_body: &B,
1457    ) -> Result<RequestBuilder, HttpClientError>
1458    where
1459        P: RequestPath,
1460        B: Serialize + ?Sized,
1461        K: AsRef<str>,
1462        V: AsRef<str>,
1463    {
1464        self.create_request(reqwest::Method::PATCH, path, params, Some(json_body))
1465    }
1466
1467    /// Create and send an HTTP GET Request with the provided path and parameters
1468    #[instrument(level = "debug", skip_all, fields(path=?path))]
1469    async fn send_get_request<P, K, V>(
1470        &self,
1471        path: P,
1472        params: Params<'_, K, V>,
1473    ) -> Result<Response, HttpClientError>
1474    where
1475        P: RequestPath + Send + Sync,
1476        K: AsRef<str> + Sync,
1477        V: AsRef<str> + Sync,
1478    {
1479        self.send_request(reqwest::Method::GET, path, params, None::<&()>)
1480            .await
1481    }
1482
1483    /// Create and send an HTTP POST Request with the provided path, parameters, and json data
1484    async fn send_post_request<P, B, K, V>(
1485        &self,
1486        path: P,
1487        params: Params<'_, K, V>,
1488        json_body: &B,
1489    ) -> Result<Response, HttpClientError>
1490    where
1491        P: RequestPath + Send + Sync,
1492        B: Serialize + ?Sized + Sync,
1493        K: AsRef<str> + Sync,
1494        V: AsRef<str> + Sync,
1495    {
1496        self.send_request(reqwest::Method::POST, path, params, Some(json_body))
1497            .await
1498    }
1499
1500    /// Create and send an HTTP DELETE Request with the provided path and parameters
1501    async fn send_delete_request<P, K, V>(
1502        &self,
1503        path: P,
1504        params: Params<'_, K, V>,
1505    ) -> Result<Response, HttpClientError>
1506    where
1507        P: RequestPath + Send + Sync,
1508        K: AsRef<str> + Sync,
1509        V: AsRef<str> + Sync,
1510    {
1511        self.send_request(reqwest::Method::DELETE, path, params, None::<&()>)
1512            .await
1513    }
1514
1515    /// Create and send an HTTP PATCH Request with the provided path, parameters, and json data
1516    async fn send_patch_request<P, B, K, V>(
1517        &self,
1518        path: P,
1519        params: Params<'_, K, V>,
1520        json_body: &B,
1521    ) -> Result<Response, HttpClientError>
1522    where
1523        P: RequestPath + Send + Sync,
1524        B: Serialize + ?Sized + Sync,
1525        K: AsRef<str> + Sync,
1526        V: AsRef<str> + Sync,
1527    {
1528        self.send_request(reqwest::Method::PATCH, path, params, Some(json_body))
1529            .await
1530    }
1531
1532    /// 'get' json data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1533    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1534    /// into the provided type `T`.
1535    #[instrument(level = "debug", skip_all, fields(path=?path))]
1536    // TODO: deprecate in favour of get_response that works based on mime type in the response
1537    async fn get_json<P, T, K, V>(
1538        &self,
1539        path: P,
1540        params: Params<'_, K, V>,
1541    ) -> Result<T, HttpClientError>
1542    where
1543        P: RequestPath + Send + Sync,
1544        for<'a> T: Deserialize<'a>,
1545        K: AsRef<str> + Sync,
1546        V: AsRef<str> + Sync,
1547    {
1548        self.get_response(path, params).await
1549    }
1550
1551    /// Attempt to parse a response object from an HTTP response
1552    async fn parse_response<T>(
1553        &self,
1554        res: Response,
1555        allow_empty: bool,
1556    ) -> Result<T, HttpClientError>
1557    where
1558        T: DeserializeOwned,
1559    {
1560        let url = Url::from(res.url());
1561        parse_response(res, allow_empty).await.inspect_err(|e| {
1562            if matches!(
1563                // if we encounter a read error while we attempt to parse it could be caused by censorship and we should
1564                // rotate hosts / enable fronting.
1565                e,
1566                HttpClientError::ResponseReadFailure {
1567                    url: _,
1568                    headers: _,
1569                    status: _,
1570                    source: _,
1571                }
1572            ) {
1573                self.maybe_rotate_hosts(Some(url.clone()));
1574                #[cfg(feature = "tunneling")]
1575                self.maybe_enable_fronting(("parse/read", url.as_str(), e));
1576            }
1577        })
1578    }
1579
1580    /// 'get' data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1581    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1582    /// into the provided type `T` based on the content type header
1583    #[instrument(level = "debug", skip_all, fields(path=?path))]
1584    async fn get_response<P, T, K, V>(
1585        &self,
1586        path: P,
1587        params: Params<'_, K, V>,
1588    ) -> Result<T, HttpClientError>
1589    where
1590        P: RequestPath + Send + Sync,
1591        for<'a> T: Deserialize<'a>,
1592        K: AsRef<str> + Sync,
1593        V: AsRef<str> + Sync,
1594    {
1595        let res = self
1596            .send_request(reqwest::Method::GET, path, params, None::<&()>)
1597            .await?;
1598
1599        self.parse_response(res, false).await
1600    }
1601
1602    /// 'post' json data to the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1603    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1604    /// into the provided type `T`.
1605    async fn post_json<P, B, T, K, V>(
1606        &self,
1607        path: P,
1608        params: Params<'_, K, V>,
1609        json_body: &B,
1610    ) -> Result<T, HttpClientError>
1611    where
1612        P: RequestPath + Send + Sync,
1613        B: Serialize + ?Sized + Sync,
1614        for<'a> T: Deserialize<'a>,
1615        K: AsRef<str> + Sync,
1616        V: AsRef<str> + Sync,
1617    {
1618        let res = self
1619            .send_request(reqwest::Method::POST, path, params, Some(json_body))
1620            .await?;
1621        self.parse_response(res, false).await
1622    }
1623
1624    /// 'delete' json data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with
1625    /// tuple defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the
1626    /// response into the provided type `T`.
1627    async fn delete_json<P, T, K, V>(
1628        &self,
1629        path: P,
1630        params: Params<'_, K, V>,
1631    ) -> Result<T, HttpClientError>
1632    where
1633        P: RequestPath + Send + Sync,
1634        for<'a> T: Deserialize<'a>,
1635        K: AsRef<str> + Sync,
1636        V: AsRef<str> + Sync,
1637    {
1638        let res = self
1639            .send_request(reqwest::Method::DELETE, path, params, None::<&()>)
1640            .await?;
1641        self.parse_response(res, false).await
1642    }
1643
1644    /// 'patch' json data at the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1645    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1646    /// into the provided type `T`.
1647    async fn patch_json<P, B, T, K, V>(
1648        &self,
1649        path: P,
1650        params: Params<'_, K, V>,
1651        json_body: &B,
1652    ) -> Result<T, HttpClientError>
1653    where
1654        P: RequestPath + Send + Sync,
1655        B: Serialize + ?Sized + Sync,
1656        for<'a> T: Deserialize<'a>,
1657        K: AsRef<str> + Sync,
1658        V: AsRef<str> + Sync,
1659    {
1660        let res = self
1661            .send_request(reqwest::Method::PATCH, path, params, Some(json_body))
1662            .await?;
1663        self.parse_response(res, false).await
1664    }
1665
1666    /// `get` json data from the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1667    /// Attempt to parse the response into the provided type `T`.
1668    async fn get_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
1669    where
1670        for<'a> T: Deserialize<'a>,
1671        S: AsRef<str> + Sync + Send,
1672    {
1673        let req = self.create_request_endpoint(reqwest::Method::GET, endpoint, None::<&()>)?;
1674        let res = self.send(req).await?;
1675        self.parse_response(res, false).await
1676    }
1677
1678    /// `post` json data to the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1679    /// Attempt to parse the response into the provided type `T`.
1680    async fn post_json_data_to<B, T, S>(
1681        &self,
1682        endpoint: S,
1683        json_body: &B,
1684    ) -> Result<T, HttpClientError>
1685    where
1686        B: Serialize + ?Sized + Sync,
1687        for<'a> T: Deserialize<'a>,
1688        S: AsRef<str> + Sync + Send,
1689    {
1690        let req = self.create_request_endpoint(reqwest::Method::POST, endpoint, Some(json_body))?;
1691        let res = self.send(req).await?;
1692        self.parse_response(res, false).await
1693    }
1694
1695    /// `delete` json data from the provided absolute endpoint, e.g.
1696    /// `"/api/v1/mixnodes?since=12345"`. Attempt to parse the response into the provided type `T`.
1697    async fn delete_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
1698    where
1699        for<'a> T: Deserialize<'a>,
1700        S: AsRef<str> + Sync + Send,
1701    {
1702        let req = self.create_request_endpoint(reqwest::Method::DELETE, endpoint, None::<&()>)?;
1703        let res = self.send(req).await?;
1704        self.parse_response(res, false).await
1705    }
1706
1707    /// `patch` json data at the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1708    /// Attempt to parse the response into the provided type `T`.
1709    async fn patch_json_data_at<B, T, S>(
1710        &self,
1711        endpoint: S,
1712        json_body: &B,
1713    ) -> Result<T, HttpClientError>
1714    where
1715        B: Serialize + ?Sized + Sync,
1716        for<'a> T: Deserialize<'a>,
1717        S: AsRef<str> + Sync + Send,
1718    {
1719        let req =
1720            self.create_request_endpoint(reqwest::Method::PATCH, endpoint, Some(json_body))?;
1721        let res = self.send(req).await?;
1722        self.parse_response(res, false).await
1723    }
1724}
1725
1726#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1727#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1728impl<C> ApiClient for C where C: ApiClientCore + Sync {}
1729
1730/// utility function that should solve the double slash problem in API urls forever.
1731fn sanitize_url<K: AsRef<str>, V: AsRef<str>>(
1732    base: &Url,
1733    request_path: impl RequestPath,
1734    params: Params<'_, K, V>,
1735) -> Url {
1736    let mut url = base.clone();
1737    let mut path_segments = url
1738        .path_segments_mut()
1739        .expect("provided validator url does not have a base!");
1740
1741    path_segments.pop_if_empty();
1742
1743    for segment in request_path.to_sanitized_segments() {
1744        path_segments.push(segment);
1745    }
1746
1747    // I don't understand why compiler couldn't figure out that it's no longer used
1748    // and can be dropped
1749    drop(path_segments);
1750
1751    if !params.is_empty() {
1752        url.query_pairs_mut().extend_pairs(params);
1753    }
1754
1755    url
1756}
1757
1758fn decode_as_text(bytes: &bytes::Bytes, headers: &HeaderMap) -> String {
1759    use encoding_rs::{Encoding, UTF_8};
1760
1761    let content_type = try_get_mime_type(headers);
1762
1763    let encoding_name = content_type
1764        .as_ref()
1765        .and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
1766        .unwrap_or("utf-8");
1767
1768    let encoding = Encoding::for_label(encoding_name.as_bytes()).unwrap_or(UTF_8);
1769
1770    let (text, _, _) = encoding.decode(bytes);
1771    text.into_owned()
1772}
1773
1774/// Attempt to parse a response object from an HTTP response
1775#[instrument(level = "debug", skip_all)]
1776pub async fn parse_response<T>(res: Response, allow_empty: bool) -> Result<T, HttpClientError>
1777where
1778    T: DeserializeOwned,
1779{
1780    let status = res.status();
1781    let headers = res.headers().clone();
1782    let url = res.url().clone();
1783
1784    tracing::trace!("status: {status} (success: {})", status.is_success());
1785    tracing::trace!("headers: {headers:?}");
1786
1787    if !allow_empty && let Some(0) = res.content_length() {
1788        return Err(HttpClientError::EmptyResponse {
1789            url: Box::new(url),
1790            status,
1791            headers: Box::new(headers),
1792        });
1793    }
1794
1795    if res.status().is_success() {
1796        // internally reqwest is first retrieving bytes and then performing parsing via serde_json
1797        // (and similarly does the same thing for text())
1798        let full = res
1799            .bytes()
1800            .await
1801            .map_err(|source| HttpClientError::ResponseReadFailure {
1802                url: Box::new(url),
1803                headers: Box::new(headers.clone()),
1804                status,
1805                source: ReqwestErrorWrapper(source),
1806            })?;
1807        decode_raw_response(&headers, full)
1808    } else if res.status() == StatusCode::NOT_FOUND {
1809        Err(HttpClientError::NotFound { url: Box::new(url) })
1810    } else if is_http_rate_limit_err(&res) {
1811        Err(HttpClientError::EndpointFailure {
1812            url: Box::new(url),
1813            status,
1814            headers: Box::new(headers),
1815            error: String::from("received rate limit response"),
1816        })
1817    } else {
1818        let Ok(plaintext) = res.text().await else {
1819            return Err(HttpClientError::RequestFailure {
1820                url: Box::new(url),
1821                status,
1822                headers: Box::new(headers),
1823            });
1824        };
1825
1826        Err(HttpClientError::EndpointFailure {
1827            url: Box::new(url),
1828            status,
1829            headers: Box::new(headers),
1830            error: plaintext,
1831        })
1832    }
1833}
1834
1835fn decode_as_json<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1836where
1837    T: DeserializeOwned,
1838{
1839    match serde_json::from_slice(&content) {
1840        Ok(data) => Ok(data),
1841        Err(err) => {
1842            let content = decode_as_text(&content, headers);
1843            Err(HttpClientError::ResponseDecodeFailure {
1844                message: err.to_string(),
1845                content,
1846            })
1847        }
1848    }
1849}
1850
1851fn decode_as_bincode<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1852where
1853    T: DeserializeOwned,
1854{
1855    use bincode::Options;
1856
1857    let opts = nym_http_api_common::make_bincode_serializer();
1858    match opts.deserialize(&content) {
1859        Ok(data) => Ok(data),
1860        Err(err) => {
1861            let content = decode_as_text(&content, headers);
1862            Err(HttpClientError::ResponseDecodeFailure {
1863                message: err.to_string(),
1864                content,
1865            })
1866        }
1867    }
1868}
1869
1870fn decode_raw_response<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1871where
1872    T: DeserializeOwned,
1873{
1874    // if content type header is missing, fallback to our old default, json
1875    let mime = try_get_mime_type(headers).unwrap_or(mime::APPLICATION_JSON);
1876
1877    debug!("attempting to parse response as {mime}");
1878
1879    // unfortunately we can't use stronger typing for subtype as "bincode" is not a defined mime type
1880    match (mime.type_(), mime.subtype().as_str()) {
1881        (mime::APPLICATION, "json") => decode_as_json(headers, content),
1882        (mime::APPLICATION, "bincode") => decode_as_bincode(headers, content),
1883        (_, _) => {
1884            debug!("unrecognised mime type {mime}. falling back to json decoding...");
1885            decode_as_json(headers, content)
1886        }
1887    }
1888}
1889
1890fn try_get_mime_type(headers: &HeaderMap) -> Option<Mime> {
1891    headers
1892        .get(CONTENT_TYPE)
1893        .and_then(|value| value.to_str().ok())
1894        .and_then(|value| value.parse::<Mime>().ok())
1895}
1896
1897#[cfg(test)]
1898mod tests;