Skip to main content

millipede_http/
client.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    sync::{Arc, Mutex},
5    time::Duration,
6};
7
8use anyhow::anyhow;
9use async_trait::async_trait;
10use futures_util::TryStreamExt;
11use http::{
12    HeaderName, HeaderValue,
13    header::{COOKIE, LOCATION, USER_AGENT},
14};
15use millipede_core::{
16    http_client::{HttpClient, HttpClientError, HttpRequest, HttpResponse, StreamingResponse},
17    request::{Method, RequestBody},
18};
19use url::Url;
20
21/// Configuration for [`ReqwestClient`].
22///
23/// # Examples
24///
25/// ```
26/// use std::time::Duration;
27/// use millipede_http::ReqwestClientOptions;
28///
29/// let options = ReqwestClientOptions::default()
30///     .with_connect_timeout(Duration::from_secs(5))
31///     .with_default_timeout(Duration::from_secs(20))
32///     .with_max_cached_clients(4)
33///     .with_default_user_agent(None);
34/// let client = millipede_http::ReqwestClient::with_options(options)?;
35/// # let _ = client;
36/// # Ok::<(), millipede_core::http_client::HttpClientError>(())
37/// ```
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40#[must_use = "client options do nothing unless passed to ReqwestClient::with_options"]
41pub struct ReqwestClientOptions {
42    /// Maximum time allowed while establishing a connection.
43    pub connect_timeout: Duration,
44    /// Request timeout used when a request does not provide one.
45    pub default_timeout: Duration,
46    /// Maximum number of proxy-specific clients retained in the simple cache.
47    pub max_cached_clients: usize,
48    /// User-Agent inserted when a request does not already contain one.
49    pub default_user_agent: Option<String>,
50    /// Generator used for deterministic browser-like request headers.
51    pub header_generator: Arc<millipede_fingerprint::HeaderGenerator>,
52}
53
54impl Default for ReqwestClientOptions {
55    fn default() -> Self {
56        Self {
57            connect_timeout: Duration::from_secs(10),
58            default_timeout: Duration::from_secs(30),
59            max_cached_clients: 8,
60            default_user_agent: Some(
61                "millipede/0.1 (+https://github.com/satvik007/millipede)".to_owned(),
62            ),
63            header_generator: Arc::new(millipede_fingerprint::HeaderGenerator::new()),
64        }
65    }
66}
67
68impl ReqwestClientOptions {
69    /// Sets the connection timeout.
70    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
71        self.connect_timeout = timeout;
72        self
73    }
74
75    /// Sets the default request timeout.
76    pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
77        self.default_timeout = timeout;
78        self
79    }
80
81    /// Sets the maximum number of cached clients.
82    pub fn with_max_cached_clients(mut self, maximum: usize) -> Self {
83        self.max_cached_clients = maximum;
84        self
85    }
86
87    /// Sets or disables the default User-Agent.
88    pub fn with_default_user_agent(mut self, user_agent: Option<String>) -> Self {
89        self.default_user_agent = user_agent;
90        self
91    }
92
93    /// Replaces the deterministic browser-like header generator.
94    pub fn with_header_generator(
95        mut self,
96        generator: Arc<millipede_fingerprint::HeaderGenerator>,
97    ) -> Self {
98        self.header_generator = generator;
99        self
100    }
101}
102
103/// A reqwest-backed [`HttpClient`] with manual redirect and cookie handling.
104///
105/// # Examples
106///
107/// ```
108/// use millipede_http::ReqwestClient;
109///
110/// let client = ReqwestClient::new()?;
111/// # Ok::<(), millipede_core::http_client::HttpClientError>(())
112/// ```
113pub struct ReqwestClient {
114    options: ReqwestClientOptions,
115    clients: Mutex<HashMap<Option<Url>, Arc<reqwest::Client>>>,
116}
117
118impl ReqwestClient {
119    /// Creates a client with default options.
120    pub fn new() -> Result<Self, HttpClientError> {
121        Self::with_options(ReqwestClientOptions::default())
122    }
123
124    /// Creates a client with the supplied options.
125    pub fn with_options(options: ReqwestClientOptions) -> Result<Self, HttpClientError> {
126        let client = Arc::new(Self::build_client(&options, None)?);
127        let mut clients = HashMap::new();
128        clients.insert(None, client);
129        Ok(Self {
130            options,
131            clients: Mutex::new(clients),
132        })
133    }
134
135    fn build_client(
136        options: &ReqwestClientOptions,
137        proxy: Option<&Url>,
138    ) -> Result<reqwest::Client, HttpClientError> {
139        let mut builder = reqwest::Client::builder()
140            .redirect(reqwest::redirect::Policy::none())
141            .connect_timeout(options.connect_timeout)
142            .timeout(options.default_timeout);
143
144        if let Some(proxy_url) = proxy {
145            let mut reqwest_proxy = reqwest::Proxy::all(proxy_url.as_str())
146                .map_err(|error| HttpClientError::build(anyhow::Error::new(error)))?;
147            if !proxy_url.username().is_empty() {
148                let username = percent_decode(proxy_url.username());
149                let password = percent_decode(proxy_url.password().unwrap_or_default());
150                reqwest_proxy = reqwest_proxy.basic_auth(&username, &password);
151            }
152            builder = builder.proxy(reqwest_proxy);
153        } else {
154            builder = builder.no_proxy();
155        }
156
157        builder
158            .build()
159            .map_err(|error| HttpClientError::build(anyhow::Error::new(error)))
160    }
161
162    fn client_for(&self, proxy: Option<&Url>) -> Result<Arc<reqwest::Client>, HttpClientError> {
163        let key = proxy.cloned();
164        let mut clients = self
165            .clients
166            .lock()
167            .unwrap_or_else(|error| error.into_inner());
168        if let Some(client) = clients.get(&key) {
169            return Ok(Arc::clone(client));
170        }
171
172        let client = Arc::new(Self::build_client(&self.options, proxy)?);
173        // This intentionally simple policy bounds proxy-specific client growth.
174        if clients.len() >= self.options.max_cached_clients {
175            clients.clear();
176        }
177        clients.insert(key, Arc::clone(&client));
178        Ok(client)
179    }
180
181    async fn execute_following_redirects(
182        &self,
183        request: &HttpRequest,
184    ) -> Result<(reqwest::Response, Vec<Url>), HttpClientError> {
185        let mut current_url = request.url.clone();
186        let mut current_method = request.method.clone();
187        let mut current_body = request.body.clone();
188        let mut chain = Vec::new();
189
190        loop {
191            let client = self.client_for(request.proxy.as_ref())?;
192            let mut headers = request.headers.clone();
193            if request.use_header_generator {
194                let seed = request
195                    .session_token
196                    .as_ref()
197                    .map(|token| token.as_str().to_owned())
198                    .unwrap_or_else(|| current_url.as_str().to_owned());
199                let profile = self.options.header_generator.generate(&seed);
200                if !headers.contains_key(USER_AGENT) {
201                    if let Ok(value) = profile.user_agent.parse() {
202                        headers.insert(USER_AGENT, value);
203                    }
204                }
205                for (name, value) in profile.headers {
206                    if let (Ok(name), Ok(value)) =
207                        (name.parse::<HeaderName>(), value.parse::<HeaderValue>())
208                    {
209                        if !headers.contains_key(&name) {
210                            headers.insert(name, value);
211                        }
212                    }
213                }
214            }
215            if !headers.contains_key(USER_AGENT) {
216                if let Some(user_agent) = &self.options.default_user_agent {
217                    let value = user_agent.parse().map_err(|error| {
218                        HttpClientError::invalid_request(anyhow!(
219                            "invalid default User-Agent: {error}"
220                        ))
221                    })?;
222                    headers.insert(USER_AGENT, value);
223                }
224            }
225            if let Some(jar) = &request.cookie_jar {
226                if let Some(jar_cookie) = jar.cookie_header_for(&current_url) {
227                    if let Some(existing) = headers.get(COOKIE) {
228                        let mut combined = Vec::with_capacity(
229                            existing.as_bytes().len() + 2 + jar_cookie.as_bytes().len(),
230                        );
231                        combined.extend_from_slice(existing.as_bytes());
232                        combined.extend_from_slice(b"; ");
233                        combined.extend_from_slice(jar_cookie.as_bytes());
234                        let value = http::HeaderValue::from_bytes(&combined).map_err(|error| {
235                            HttpClientError::invalid_request(anyhow::Error::new(error))
236                        })?;
237                        headers.insert(COOKIE, value);
238                    } else {
239                        headers.insert(COOKIE, jar_cookie);
240                    }
241                }
242            }
243
244            let mut builder = client
245                .request(current_method.clone(), current_url.clone())
246                .headers(headers);
247            if let Some(body) = &current_body {
248                builder = match body {
249                    RequestBody::Bytes(bytes) => builder.body(bytes.clone()),
250                    RequestBody::Form(pairs) => builder.form(pairs),
251                    RequestBody::Json(value) => builder.json(value),
252                };
253            }
254            if let Some(timeout) = request.timeout {
255                builder = builder.timeout(timeout);
256            }
257
258            let response = builder.send().await.map_err(map_reqwest_error)?;
259            let status = response.status();
260            if let Some(jar) = &request.cookie_jar {
261                jar.store_response_cookies(&current_url, response.headers());
262            }
263
264            if status.is_redirection() {
265                if let Some(location) = response.headers().get(LOCATION) {
266                    if chain.len() as u32 >= request.max_redirects {
267                        return Err(HttpClientError::redirect(anyhow!(
268                            "exceeded {} redirects",
269                            request.max_redirects
270                        )));
271                    }
272                    let location = location.to_str().map_err(|error| {
273                        HttpClientError::redirect(anyhow!(
274                            "invalid redirect Location header: {error}"
275                        ))
276                    })?;
277                    let next_url = current_url.join(location).map_err(|error| {
278                        HttpClientError::redirect(anyhow!("invalid redirect target: {error}"))
279                    })?;
280                    chain.push(current_url);
281
282                    // Match browser-compatible behavior for legacy POST redirects.
283                    if status == http::StatusCode::SEE_OTHER
284                        || ((status == http::StatusCode::MOVED_PERMANENTLY
285                            || status == http::StatusCode::FOUND)
286                            && current_method != Method::GET
287                            && current_method != Method::HEAD)
288                    {
289                        current_method = Method::GET;
290                        current_body = None;
291                    }
292                    current_url = next_url;
293                    continue;
294                }
295            }
296
297            return Ok((response, chain));
298        }
299    }
300}
301
302#[async_trait]
303impl HttpClient for ReqwestClient {
304    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpClientError> {
305        let (response, chain) = self.execute_following_redirects(&request).await?;
306        let url = response.url().clone();
307        let status = response.status();
308        let headers = response.headers().clone();
309        let body = response
310            .bytes()
311            .await
312            .map_err(|error| HttpClientError::decode(anyhow::Error::new(error)))?;
313        Ok(HttpResponse::new(url, status, headers, body).with_redirect_chain(chain))
314    }
315
316    async fn stream(&self, request: HttpRequest) -> Result<StreamingResponse, HttpClientError> {
317        let (response, _chain) = self.execute_following_redirects(&request).await?;
318        let url = response.url().clone();
319        let status = response.status();
320        let headers = response.headers().clone();
321        let body = response
322            .bytes_stream()
323            .map_err(|error| HttpClientError::io(anyhow::Error::new(error)));
324        Ok(StreamingResponse::new(url, status, headers, Box::pin(body)))
325    }
326}
327
328impl fmt::Debug for ReqwestClient {
329    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330        let cached_clients = self
331            .clients
332            .lock()
333            .unwrap_or_else(|error| error.into_inner())
334            .len();
335        formatter
336            .debug_struct("ReqwestClient")
337            .field("options", &self.options)
338            .field("cached_clients", &cached_clients)
339            .finish()
340    }
341}
342
343fn map_reqwest_error(error: reqwest::Error) -> HttpClientError {
344    if error.is_timeout() {
345        HttpClientError::timeout(anyhow::Error::new(error))
346    } else if error.is_connect() {
347        HttpClientError::connect(anyhow::Error::new(error))
348    } else if error.is_builder() || error.is_request() {
349        HttpClientError::invalid_request(anyhow::Error::new(error))
350    } else if error.is_decode() || error.is_body() {
351        HttpClientError::decode(anyhow::Error::new(error))
352    } else {
353        HttpClientError::other(anyhow::Error::new(error))
354    }
355}
356
357fn percent_decode(value: &str) -> String {
358    let bytes = value.as_bytes();
359    let mut decoded = Vec::with_capacity(bytes.len());
360    let mut index = 0;
361    while index < bytes.len() {
362        if bytes[index] == b'%' && index + 2 < bytes.len() {
363            if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
364                decoded.push(high * 16 + low);
365                index += 3;
366                continue;
367            }
368        }
369        decoded.push(bytes[index]);
370        index += 1;
371    }
372    String::from_utf8_lossy(&decoded).into_owned()
373}
374
375fn hex(byte: u8) -> Option<u8> {
376    match byte {
377        b'0'..=b'9' => Some(byte - b'0'),
378        b'a'..=b'f' => Some(byte - b'a' + 10),
379        b'A'..=b'F' => Some(byte - b'A' + 10),
380        _ => None,
381    }
382}