Skip to main content

mq_crawler/
http_client.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Duration;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use chromiumoxide::cdp::browser_protocol::page::EventLifecycleEvent;
8use futures::StreamExt;
9use reqwest::Client as ReqwestClient;
10use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
11use tokio::time::sleep;
12use url::Url;
13
14/// Exponential backoff config for retrying failed requests.
15///
16/// Retries on network errors, `429`, and `5xx`; other client errors (e.g. `404`) are not retried.
17#[derive(Debug, Clone, PartialEq)]
18pub struct RetryConfig {
19    /// Retry attempts after the initial request fails.
20    pub max_retries: u32,
21    /// Delay before the first retry.
22    pub initial_backoff: Duration,
23    /// Upper bound on the backoff delay.
24    pub max_backoff: Duration,
25    /// Multiplier applied to the backoff delay after each attempt.
26    pub backoff_multiplier: f64,
27}
28
29impl Default for RetryConfig {
30    fn default() -> Self {
31        Self {
32            max_retries: 3,
33            initial_backoff: Duration::from_millis(500),
34            max_backoff: Duration::from_secs(10),
35            backoff_multiplier: 2.0,
36        }
37    }
38}
39
40impl RetryConfig {
41    /// Disables retries; a failed request fails immediately.
42    pub fn disabled() -> Self {
43        Self {
44            max_retries: 0,
45            ..Default::default()
46        }
47    }
48
49    fn next_backoff(&self, current: Duration) -> Duration {
50        let multiplier = self.backoff_multiplier.max(1.0);
51        Duration::from_secs_f64(current.as_secs_f64() * multiplier).min(self.max_backoff)
52    }
53}
54
55/// Default `Authorization` header credentials for a `reqwest`-based [`HttpClient`].
56#[derive(Debug, Clone)]
57pub enum AuthConfig {
58    /// `Authorization: Basic <base64(user:pass)>`.
59    Basic { username: String, password: Option<String> },
60    /// `Authorization: Bearer <token>`.
61    Bearer { token: String },
62}
63
64/// Transient (worth retrying) vs. fatal failures.
65enum FetchError {
66    Retryable(String),
67    Fatal(String),
68}
69
70impl FetchError {
71    fn into_message(self) -> String {
72        match self {
73            FetchError::Retryable(msg) | FetchError::Fatal(msg) => msg,
74        }
75    }
76}
77
78/// Wait strategy configuration for headless Chrome.
79///
80/// These strategies are applied after the browser's `load` event fires.
81/// Multiple strategies can be combined; they are executed in order:
82/// network-idle / selector wait first, then the fixed delay.
83#[derive(Debug, Clone, Default)]
84pub struct ChromiumWaitConfig {
85    /// Optional fixed delay applied after all other strategies complete.
86    pub fixed_delay: Duration,
87    /// If set, poll for this CSS selector to appear in the DOM before
88    /// proceeding. Times out after `strategy_timeout`.
89    pub wait_for_selector: Option<String>,
90    /// If `true`, wait for the browser's `networkIdle` CDP lifecycle event
91    /// before proceeding. Times out after `strategy_timeout`.
92    pub network_idle: bool,
93    /// Maximum time to wait for `wait_for_selector` or `network_idle`.
94    /// Defaults to 30 seconds.
95    pub strategy_timeout: Duration,
96}
97
98/// Enum for different HTTP client implementations
99#[derive(Debug, Clone)]
100pub enum HttpClient {
101    Reqwest(ReqwestClient, RetryConfig),
102    Fantoccini(fantoccini::Client, RetryConfig),
103    /// Headless Chrome via CDP.
104    /// `new_page()` waits for the `load` event, which covers synchronous JS
105    /// execution. Additional wait strategies in [`ChromiumWaitConfig`] can be
106    /// used to handle SPAs that fetch data asynchronously after load.
107    Chromium(
108        Arc<chromiumoxide::Browser>,
109        ChromiumWaitConfig,
110        RetryConfig,
111        Option<Arc<tempfile::TempDir>>,
112    ),
113}
114
115impl Default for HttpClient {
116    fn default() -> Self {
117        Self::Reqwest(
118            ReqwestClient::builder()
119                .user_agent(format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE")))
120                .build()
121                .expect("Failed to build default reqwest client"),
122            RetryConfig::default(),
123        )
124    }
125}
126
127impl HttpClient {
128    /// Create a new reqwest-based HTTP client optimized for single-domain crawling
129    pub fn new_reqwest(timeout: f64) -> Result<Self, String> {
130        Self::new_reqwest_with_options(timeout, 3, RetryConfig::default(), HeaderMap::new(), None)
131    }
132
133    /// Create a new reqwest-based HTTP client optimized for multi-domain crawling
134    pub fn new_reqwest_multi_domain(timeout: f64, max_idle_per_host: usize) -> Result<Self, String> {
135        Self::new_reqwest_with_options(
136            timeout,
137            max_idle_per_host,
138            RetryConfig::default(),
139            HeaderMap::new(),
140            None,
141        )
142    }
143
144    /// Create a reqwest-based HTTP client with retry, default headers/cookies, and auth.
145    ///
146    /// `auth`, if set, overrides any `Authorization` entry already in `headers`.
147    pub fn new_reqwest_with_options(
148        timeout: f64,
149        max_idle_per_host: usize,
150        retry_config: RetryConfig,
151        headers: HeaderMap,
152        auth: Option<AuthConfig>,
153    ) -> Result<Self, String> {
154        let mut header_map = headers;
155
156        if let Some(auth) = auth {
157            let value = match auth {
158                AuthConfig::Basic { username, password } => {
159                    let credentials = format!("{}:{}", username, password.unwrap_or_default());
160                    format!("Basic {}", BASE64.encode(credentials))
161                }
162                AuthConfig::Bearer { token } => format!("Bearer {}", token),
163            };
164            let header_value = HeaderValue::from_str(&value).map_err(|e| format!("Invalid auth credentials: {}", e))?;
165            header_map.insert(AUTHORIZATION, header_value);
166        }
167
168        let mut builder = ReqwestClient::builder()
169            .user_agent(format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE")))
170            .pool_max_idle_per_host(max_idle_per_host)
171            .pool_idle_timeout(Duration::from_secs(90))
172            .timeout(Duration::from_secs(timeout as u64))
173            .connect_timeout(Duration::from_secs(10))
174            .tcp_keepalive(Duration::from_secs(120));
175
176        if !header_map.is_empty() {
177            builder = builder.default_headers(header_map);
178        }
179
180        let client = builder
181            .build()
182            .map_err(|e| format!("Failed to build reqwest client: {}", e))?;
183        Ok(Self::Reqwest(client, retry_config))
184    }
185
186    /// Create a headless Chrome client that launches Chrome/Chromium automatically.
187    /// No external WebDriver server is required — only Chrome/Chromium must be installed.
188    /// If `chrome_path` is `None`, the system Chrome is auto-detected.
189    ///
190    /// Pages are fetched after the browser's `load` event fires, which includes
191    /// synchronous JavaScript execution. Additional wait strategies in
192    /// [`ChromiumWaitConfig`] (network-idle, CSS selector polling, fixed delay)
193    /// can be layered on top for SPAs that fetch data asynchronously after load.
194    pub async fn new_chromium(
195        chrome_path: Option<PathBuf>,
196        wait_config: ChromiumWaitConfig,
197        retry_config: RetryConfig,
198    ) -> Result<Self, String> {
199        let mut config_builder = chromiumoxide::browser::BrowserConfig::builder().arg("--disable-gpu");
200
201        if let Some(path) = chrome_path {
202            config_builder = config_builder.chrome_executable(path);
203        }
204
205        let temp_dir = tempfile::tempdir().map_err(|e| format!("Failed to create temporary directory: {}", e))?;
206        config_builder = config_builder.user_data_dir(temp_dir.path());
207
208        let config = config_builder
209            .build()
210            .map_err(|e| format!("Failed to build Chrome config: {}", e))?;
211
212        let (browser, mut handler) = chromiumoxide::Browser::launch(config)
213            .await
214            .map_err(|e| format!("Failed to launch Chrome: {}", e))?;
215
216        // Run the browser event loop in a background task.
217        // Errors from individual events are logged but do not stop the loop —
218        // breaking early would drop the receiver and cause all subsequent
219        // page operations to fail with "send failed because receiver is gone".
220        tokio::spawn(async move {
221            while let Some(h) = handler.next().await {
222                if let Err(e) = h {
223                    tracing::debug!("Browser handler event error: {}", e);
224                }
225            }
226        });
227
228        Ok(Self::Chromium(
229            Arc::new(browser),
230            wait_config,
231            retry_config,
232            Some(Arc::new(temp_dir)),
233        ))
234    }
235
236    fn retry_config(&self) -> &RetryConfig {
237        match self {
238            HttpClient::Reqwest(_, retry_config) => retry_config,
239            HttpClient::Fantoccini(_, retry_config) => retry_config,
240            HttpClient::Chromium(_, _, retry_config, _) => retry_config,
241        }
242    }
243
244    /// Fetch content from a URL, retrying transient failures with
245    /// exponential backoff according to this client's [`RetryConfig`].
246    pub async fn fetch(&self, url: Url) -> Result<String, String> {
247        let retry_config = self.retry_config().clone();
248        let mut backoff = retry_config.initial_backoff;
249        let mut attempt: u32 = 0;
250
251        loop {
252            match self.fetch_once(url.clone()).await {
253                Ok(content) => return Ok(content),
254                Err(FetchError::Retryable(msg)) if attempt < retry_config.max_retries => {
255                    attempt += 1;
256                    tracing::warn!(
257                        "Fetch attempt {}/{} failed for {}: {}. Retrying in {:?}.",
258                        attempt,
259                        retry_config.max_retries,
260                        url,
261                        msg,
262                        backoff
263                    );
264                    sleep(backoff).await;
265                    backoff = retry_config.next_backoff(backoff);
266                }
267                Err(err) => return Err(err.into_message()),
268            }
269        }
270    }
271
272    async fn fetch_once(&self, url: Url) -> Result<String, FetchError> {
273        match self {
274            HttpClient::Reqwest(client, _) => {
275                let response = client
276                    .get(url.clone())
277                    .send()
278                    .await
279                    .map_err(|e| FetchError::Retryable(format!("Failed to fetch URL {}: {}", url, e)))?;
280
281                let status = response.status();
282                if status.is_success() {
283                    response
284                        .text()
285                        .await
286                        .map_err(|e| FetchError::Retryable(format!("Failed to read response text: {}", e)))
287                } else if status.as_u16() == 429 || status.is_server_error() {
288                    Err(FetchError::Retryable(format!(
289                        "Request to {} failed with status: {}",
290                        url, status
291                    )))
292                } else {
293                    Err(FetchError::Fatal(format!(
294                        "Request to {} failed with status: {}",
295                        url, status
296                    )))
297                }
298            }
299            HttpClient::Fantoccini(client, _) => {
300                let url_str = url.as_str();
301
302                client
303                    .goto(url_str)
304                    .await
305                    .map_err(|e| FetchError::Retryable(format!("Fantoccini failed to navigate to {}: {}", url, e)))?;
306
307                let page_source = client
308                    .source()
309                    .await
310                    .map_err(|e| FetchError::Retryable(format!("Fantoccini failed to get page source: {}", e)))?;
311
312                Ok(page_source)
313            }
314            HttpClient::Chromium(browser, config, _, _) => {
315                // Open a blank page first so we can register event listeners
316                // BEFORE navigating. This eliminates the race condition where
317                // networkIdle fires between the `load` event and listener
318                // registration when using new_page(url) directly.
319                let page = browser
320                    .new_page("about:blank")
321                    .await
322                    .map_err(|e| FetchError::Retryable(format!("Chrome failed to open blank page: {}", e)))?;
323
324                // Strategy 1: register the networkIdle listener BEFORE navigation
325                // so no lifecycle event can slip past between load and registration.
326                let network_idle_listener = if config.network_idle {
327                    match page.event_listener::<EventLifecycleEvent>().await {
328                        Ok(events) => Some(events),
329                        Err(e) => {
330                            tracing::warn!("Failed to register networkIdle listener for {}: {}", url, e);
331                            None
332                        }
333                    }
334                } else {
335                    None
336                };
337
338                let result = async {
339                    // Navigate to the target URL after the listener is in place.
340                    page.goto(url.as_str())
341                        .await
342                        .map_err(|e| format!("Chrome failed to navigate to {}: {}", url, e))?;
343
344                    // Now await the networkIdle event from the already-registered listener.
345                    if let Some(mut events) = network_idle_listener {
346                        let timeout = if config.strategy_timeout.is_zero() {
347                            Duration::from_secs(30)
348                        } else {
349                            config.strategy_timeout
350                        };
351
352                        let _ = tokio::time::timeout(timeout, async {
353                            while let Some(event) = events.next().await {
354                                if event.name == "networkIdle" {
355                                    break;
356                                }
357                            }
358                        })
359                        .await;
360                    }
361
362                    // Strategy 2: poll until a CSS selector appears in the DOM.
363                    // Useful when you know a specific element that the SPA renders
364                    // once its content is ready (e.g. `--headless-wait-for-selector "main"`).
365                    if let Some(selector) = &config.wait_for_selector {
366                        let timeout = if config.strategy_timeout.is_zero() {
367                            Duration::from_secs(30)
368                        } else {
369                            config.strategy_timeout
370                        };
371                        let deadline = tokio::time::Instant::now() + timeout;
372                        loop {
373                            match page.find_element(selector.clone()).await {
374                                Ok(_) => break,
375                                Err(_) => {
376                                    if tokio::time::Instant::now() >= deadline {
377                                        tracing::warn!("Timed out waiting for selector '{}' on {}", selector, url);
378                                        break;
379                                    }
380                                    tokio::time::sleep(Duration::from_millis(200)).await;
381                                }
382                            }
383                        }
384                    }
385
386                    // Strategy 3: fixed delay — applied on top of other strategies.
387                    if !config.fixed_delay.is_zero() {
388                        tokio::time::sleep(config.fixed_delay).await;
389                    }
390
391                    page.content()
392                        .await
393                        .map_err(|e| format!("Chrome failed to get content from {}: {}", url, e))
394                }
395                .await;
396
397                let _ = page.close().await;
398
399                result.map_err(FetchError::Retryable)
400            }
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_default_client_creation() {
411        let client = HttpClient::default();
412        assert!(matches!(client, HttpClient::Reqwest(_, _)));
413    }
414
415    #[test]
416    fn test_new_reqwest_client() {
417        let client = HttpClient::new_reqwest(30.0).unwrap();
418        assert!(matches!(client, HttpClient::Reqwest(_, _)));
419        assert_eq!(client.retry_config(), &RetryConfig::default());
420    }
421
422    #[test]
423    fn test_new_reqwest_with_basic_auth() {
424        let client = HttpClient::new_reqwest_with_options(
425            30.0,
426            3,
427            RetryConfig::default(),
428            HeaderMap::new(),
429            Some(AuthConfig::Basic {
430                username: "user".to_string(),
431                password: Some("pass".to_string()),
432            }),
433        )
434        .unwrap();
435        assert!(matches!(client, HttpClient::Reqwest(_, _)));
436    }
437
438    #[test]
439    fn test_new_reqwest_with_bearer_auth() {
440        let client = HttpClient::new_reqwest_with_options(
441            30.0,
442            3,
443            RetryConfig::default(),
444            HeaderMap::new(),
445            Some(AuthConfig::Bearer {
446                token: "secret-token".to_string(),
447            }),
448        )
449        .unwrap();
450        assert!(matches!(client, HttpClient::Reqwest(_, _)));
451    }
452
453    #[test]
454    fn test_retry_config_next_backoff_respects_max() {
455        let retry_config = RetryConfig {
456            max_retries: 5,
457            initial_backoff: Duration::from_secs(1),
458            max_backoff: Duration::from_secs(3),
459            backoff_multiplier: 2.0,
460        };
461
462        let backoff = retry_config.next_backoff(Duration::from_secs(1));
463        assert_eq!(backoff, Duration::from_secs(2));
464
465        let backoff = retry_config.next_backoff(backoff);
466        assert_eq!(backoff, Duration::from_secs(3));
467
468        let backoff = retry_config.next_backoff(backoff);
469        assert_eq!(backoff, Duration::from_secs(3));
470    }
471
472    #[test]
473    fn test_retry_config_disabled() {
474        let retry_config = RetryConfig::disabled();
475        assert_eq!(retry_config.max_retries, 0);
476    }
477
478    #[tokio::test]
479    async fn test_fetch_retries_on_server_error_then_succeeds() {
480        use httpmock::MockServer;
481
482        let server = MockServer::start_async().await;
483        let mock = server
484            .mock_async(|when, then| {
485                when.method(httpmock::Method::GET).path("/flaky");
486                then.status(503);
487            })
488            .await;
489
490        let retry_config = RetryConfig {
491            max_retries: 2,
492            initial_backoff: Duration::from_millis(1),
493            max_backoff: Duration::from_millis(5),
494            backoff_multiplier: 2.0,
495        };
496        let client = HttpClient::new_reqwest_with_options(5.0, 3, retry_config, HeaderMap::new(), None).unwrap();
497        let url = Url::parse(&format!("http://{}/flaky", server.address())).unwrap();
498
499        let result = client.fetch(url).await;
500        assert!(result.is_err());
501        assert_eq!(mock.calls_async().await, 3);
502    }
503
504    #[tokio::test]
505    async fn test_fetch_does_not_retry_on_client_error() {
506        use httpmock::MockServer;
507
508        let server = MockServer::start_async().await;
509        let mock = server
510            .mock_async(|when, then| {
511                when.method(httpmock::Method::GET).path("/missing");
512                then.status(404);
513            })
514            .await;
515
516        let retry_config = RetryConfig {
517            max_retries: 3,
518            initial_backoff: Duration::from_millis(1),
519            max_backoff: Duration::from_millis(5),
520            backoff_multiplier: 2.0,
521        };
522        let client = HttpClient::new_reqwest_with_options(5.0, 3, retry_config, HeaderMap::new(), None).unwrap();
523        let url = Url::parse(&format!("http://{}/missing", server.address())).unwrap();
524
525        let result = client.fetch(url).await;
526        assert!(result.is_err());
527        assert_eq!(mock.calls_async().await, 1);
528    }
529}