Skip to main content

spire_reqwest/
client.rs

1use std::future::{Future, ready};
2use std::sync::Arc;
3
4use spire_core::backend::{Backend, Session, StatusPolicy};
5use spire_core::http::{Request, Response};
6use spire_core::identity::Identity;
7use spire_core::{Error, ErrorKind, Result};
8
9use crate::clients::Clients;
10use crate::conversion::{head_from_reqwest, request_to_reqwest};
11
12/// An HTTP [`Backend`] built on [`reqwest::Client`].
13///
14/// Every request of a crawl gets a [`ReqwestSession`], and all of them share
15/// one reqwest client: its connection pool, cookie store and
16/// configuration. Error statuses are turned into errors according to the
17/// [`StatusPolicy`].
18///
19/// It is built from a function that makes the reqwest configuration, see
20/// [`from_builder`](Self::from_builder), because a crawl with
21/// [identities](spire_core::identity) gets a client per identity from it,
22/// with the identity's proxy and, with the `cookies` feature, a cookie store
23/// of its own. An identity's client is dropped once the identity is gone.
24///
25/// # Examples
26///
27/// ```
28/// use std::time::Duration;
29///
30/// use spire_reqwest::{ReqwestBackend, StatusPolicy};
31///
32/// let backend = ReqwestBackend::from_builder(|| {
33///     reqwest::Client::builder()
34///         .timeout(Duration::from_secs(30))
35///         .user_agent("MyBot/1.0")
36/// })?
37/// .with_status_policy(StatusPolicy::Accept);
38/// # Ok::<_, spire_core::Error>(())
39/// ```
40#[derive(Debug, Clone, Default)]
41pub struct ReqwestBackend {
42    clients: Clients,
43    status_policy: StatusPolicy,
44}
45
46impl ReqwestBackend {
47    /// Creates a client with reqwest's default configuration.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Creates a client from the configuration `builder` makes, which
54    /// identities' clients are built from too.
55    ///
56    /// ```
57    /// use std::time::Duration;
58    ///
59    /// use spire_reqwest::ReqwestBackend;
60    ///
61    /// let backend = ReqwestBackend::from_builder(|| {
62    ///     reqwest::Client::builder().timeout(Duration::from_secs(30))
63    /// })?;
64    /// # Ok::<_, spire_core::Error>(())
65    /// ```
66    ///
67    /// # Errors
68    ///
69    /// Returns a [`Backend`](ErrorKind::Backend) error if reqwest cannot
70    /// build the client, e.g. when TLS fails to initialize.
71    pub fn from_builder<F>(builder: F) -> Result<Self>
72    where
73        F: Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
74    {
75        Ok(Self {
76            clients: Clients::new(Arc::new(builder))?,
77            status_policy: StatusPolicy::default(),
78        })
79    }
80
81    /// Sets how error statuses are treated.
82    #[must_use]
83    pub const fn with_status_policy(mut self, policy: StatusPolicy) -> Self {
84        self.status_policy = policy;
85        self
86    }
87
88    /// Returns the [`reqwest::Client`] requests without an identity use.
89    #[must_use]
90    pub const fn as_reqwest(&self) -> &reqwest::Client {
91        self.clients.direct()
92    }
93
94    /// Returns the [`StatusPolicy`].
95    #[must_use]
96    pub const fn status_policy(&self) -> StatusPolicy {
97        self.status_policy
98    }
99}
100
101impl Backend for ReqwestBackend {
102    type Session = ReqwestSession;
103
104    fn connect(&self) -> impl Future<Output = Result<Self::Session>> + Send {
105        ready(Ok(self.session(self.clients.direct().clone())))
106    }
107
108    /// Sends the identity's requests with a reqwest client of its own, with
109    /// its proxy and cookies.
110    fn connect_as(
111        &self,
112        identity: &Identity,
113    ) -> impl Future<Output = Result<Self::Session>> + Send {
114        ready(self.clients.of(identity).map(|client| self.session(client)))
115    }
116}
117
118impl ReqwestBackend {
119    fn session(&self, client: reqwest::Client) -> ReqwestSession {
120        ReqwestSession {
121            http: self.clone(),
122            client,
123        }
124    }
125}
126
127/// The [`Session`] a [`ReqwestBackend`] hands out for one request: it sends the
128/// request and returns the complete response.
129#[derive(Debug)]
130pub struct ReqwestSession {
131    http: ReqwestBackend,
132    /// The reqwest client it sends with: the crawl's, or its identity's.
133    client: reqwest::Client,
134}
135
136impl ReqwestSession {
137    /// Returns the [`ReqwestBackend`] the session belongs to.
138    #[must_use]
139    pub const fn http(&self) -> &ReqwestBackend {
140        &self.http
141    }
142}
143
144impl Session for ReqwestSession {
145    async fn fetch(&self, request: &Request) -> Result<Response> {
146        let uri = request.uri().clone();
147        let failed = |error: Error| error.with_uri(uri.clone());
148        let converted = request_to_reqwest(request).map_err(failed)?;
149        let response = self
150            .client
151            .execute(converted)
152            .await
153            .map_err(|error| failed(classify(error)))?;
154
155        // An error status is rejected before its body is downloaded.
156        let head = self
157            .http
158            .status_policy
159            .check(head_from_reqwest(&response))?;
160        let body = response
161            .bytes()
162            .await
163            .map_err(|error| failed(classify(error)))?;
164        Ok(head.map(|()| body))
165    }
166}
167
168/// Classifies a failed transfer, so the crawler only retries what may
169/// succeed on another attempt.
170fn classify(error: reqwest::Error) -> Error {
171    let kind = if error.is_timeout() {
172        ErrorKind::Timeout
173    } else if error.is_builder() {
174        ErrorKind::Invalid
175    } else if error.is_redirect() {
176        // Too many redirects or a redirect loop; retrying loops again.
177        ErrorKind::Rejected
178    } else {
179        // Connection, DNS, protocol and body errors are usually transient.
180        ErrorKind::Http
181    };
182    Error::new(kind).with_source(error)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[tokio::test]
190    async fn invalid_request_is_rejected_before_sending() {
191        let session = ReqwestBackend::new().connect().await.unwrap();
192        let error = session.fetch(&Request::default()).await.unwrap_err();
193        assert_eq!(error.kind(), ErrorKind::Invalid);
194    }
195
196    #[tokio::test]
197    async fn sessions_share_the_client() {
198        let backend = ReqwestBackend::new().with_status_policy(StatusPolicy::Accept);
199        let session = backend.connect().await.unwrap();
200        assert_eq!(session.http().status_policy(), StatusPolicy::Accept);
201    }
202}