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#[derive(Debug, Clone, PartialEq)]
18pub struct RetryConfig {
19 pub max_retries: u32,
21 pub initial_backoff: Duration,
23 pub max_backoff: Duration,
25 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 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#[derive(Debug, Clone)]
57pub enum AuthConfig {
58 Basic { username: String, password: Option<String> },
60 Bearer { token: String },
62}
63
64enum 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#[derive(Debug, Clone, Default)]
84pub struct ChromiumWaitConfig {
85 pub fixed_delay: Duration,
87 pub wait_for_selector: Option<String>,
90 pub network_idle: bool,
93 pub strategy_timeout: Duration,
96}
97
98#[derive(Debug, Clone)]
100pub enum HttpClient {
101 Reqwest(ReqwestClient, RetryConfig),
102 Fantoccini(fantoccini::Client, RetryConfig),
103 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 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 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 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 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 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 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 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 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 page.goto(url.as_str())
341 .await
342 .map_err(|e| format!("Chrome failed to navigate to {}: {}", url, e))?;
343
344 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 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 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}