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