use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chromiumoxide::cdp::browser_protocol::page::EventLifecycleEvent;
use futures::StreamExt;
use reqwest::Client as ReqwestClient;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use tokio::time::sleep;
use url::Url;
#[derive(Debug, Clone, PartialEq)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
backoff_multiplier: 2.0,
}
}
}
impl RetryConfig {
pub fn disabled() -> Self {
Self {
max_retries: 0,
..Default::default()
}
}
fn next_backoff(&self, current: Duration) -> Duration {
let multiplier = self.backoff_multiplier.max(1.0);
Duration::from_secs_f64(current.as_secs_f64() * multiplier).min(self.max_backoff)
}
}
#[derive(Debug, Clone)]
pub enum AuthConfig {
Basic { username: String, password: Option<String> },
Bearer { token: String },
}
enum FetchError {
Retryable(String),
Fatal(String),
}
impl FetchError {
fn into_message(self) -> String {
match self {
FetchError::Retryable(msg) | FetchError::Fatal(msg) => msg,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChromiumWaitConfig {
pub fixed_delay: Duration,
pub wait_for_selector: Option<String>,
pub network_idle: bool,
pub strategy_timeout: Duration,
}
#[derive(Debug, Clone)]
pub enum HttpClient {
Reqwest(ReqwestClient, RetryConfig),
Fantoccini(fantoccini::Client, RetryConfig),
Chromium(
Arc<chromiumoxide::Browser>,
ChromiumWaitConfig,
RetryConfig,
Option<Arc<tempfile::TempDir>>,
),
}
impl Default for HttpClient {
fn default() -> Self {
Self::Reqwest(
ReqwestClient::builder()
.user_agent(format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE")))
.build()
.expect("Failed to build default reqwest client"),
RetryConfig::default(),
)
}
}
impl HttpClient {
pub fn new_reqwest(timeout: f64) -> Result<Self, String> {
Self::new_reqwest_with_options(timeout, 3, RetryConfig::default(), HeaderMap::new(), None)
}
pub fn new_reqwest_multi_domain(timeout: f64, max_idle_per_host: usize) -> Result<Self, String> {
Self::new_reqwest_with_options(
timeout,
max_idle_per_host,
RetryConfig::default(),
HeaderMap::new(),
None,
)
}
pub fn new_reqwest_with_options(
timeout: f64,
max_idle_per_host: usize,
retry_config: RetryConfig,
headers: HeaderMap,
auth: Option<AuthConfig>,
) -> Result<Self, String> {
let mut header_map = headers;
if let Some(auth) = auth {
let value = match auth {
AuthConfig::Basic { username, password } => {
let credentials = format!("{}:{}", username, password.unwrap_or_default());
format!("Basic {}", BASE64.encode(credentials))
}
AuthConfig::Bearer { token } => format!("Bearer {}", token),
};
let header_value = HeaderValue::from_str(&value).map_err(|e| format!("Invalid auth credentials: {}", e))?;
header_map.insert(AUTHORIZATION, header_value);
}
let mut builder = ReqwestClient::builder()
.user_agent(format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE")))
.pool_max_idle_per_host(max_idle_per_host)
.pool_idle_timeout(Duration::from_secs(90))
.timeout(Duration::from_secs(timeout as u64))
.connect_timeout(Duration::from_secs(10))
.tcp_keepalive(Duration::from_secs(120));
if !header_map.is_empty() {
builder = builder.default_headers(header_map);
}
let client = builder
.build()
.map_err(|e| format!("Failed to build reqwest client: {}", e))?;
Ok(Self::Reqwest(client, retry_config))
}
pub async fn new_chromium(
chrome_path: Option<PathBuf>,
wait_config: ChromiumWaitConfig,
retry_config: RetryConfig,
) -> Result<Self, String> {
let mut config_builder = chromiumoxide::browser::BrowserConfig::builder().arg("--disable-gpu");
if let Some(path) = chrome_path {
config_builder = config_builder.chrome_executable(path);
}
let temp_dir = tempfile::tempdir().map_err(|e| format!("Failed to create temporary directory: {}", e))?;
config_builder = config_builder.user_data_dir(temp_dir.path());
let config = config_builder
.build()
.map_err(|e| format!("Failed to build Chrome config: {}", e))?;
let (browser, mut handler) = chromiumoxide::Browser::launch(config)
.await
.map_err(|e| format!("Failed to launch Chrome: {}", e))?;
tokio::spawn(async move {
while let Some(h) = handler.next().await {
if let Err(e) = h {
tracing::debug!("Browser handler event error: {}", e);
}
}
});
Ok(Self::Chromium(
Arc::new(browser),
wait_config,
retry_config,
Some(Arc::new(temp_dir)),
))
}
fn retry_config(&self) -> &RetryConfig {
match self {
HttpClient::Reqwest(_, retry_config) => retry_config,
HttpClient::Fantoccini(_, retry_config) => retry_config,
HttpClient::Chromium(_, _, retry_config, _) => retry_config,
}
}
pub async fn fetch(&self, url: Url) -> Result<String, String> {
let retry_config = self.retry_config().clone();
let mut backoff = retry_config.initial_backoff;
let mut attempt: u32 = 0;
loop {
match self.fetch_once(url.clone()).await {
Ok(content) => return Ok(content),
Err(FetchError::Retryable(msg)) if attempt < retry_config.max_retries => {
attempt += 1;
tracing::warn!(
"Fetch attempt {}/{} failed for {}: {}. Retrying in {:?}.",
attempt,
retry_config.max_retries,
url,
msg,
backoff
);
sleep(backoff).await;
backoff = retry_config.next_backoff(backoff);
}
Err(err) => return Err(err.into_message()),
}
}
}
async fn fetch_once(&self, url: Url) -> Result<String, FetchError> {
match self {
HttpClient::Reqwest(client, _) => {
let response = client
.get(url.clone())
.send()
.await
.map_err(|e| FetchError::Retryable(format!("Failed to fetch URL {}: {}", url, e)))?;
let status = response.status();
if status.is_success() {
response
.text()
.await
.map_err(|e| FetchError::Retryable(format!("Failed to read response text: {}", e)))
} else if status.as_u16() == 429 || status.is_server_error() {
Err(FetchError::Retryable(format!(
"Request to {} failed with status: {}",
url, status
)))
} else {
Err(FetchError::Fatal(format!(
"Request to {} failed with status: {}",
url, status
)))
}
}
HttpClient::Fantoccini(client, _) => {
let url_str = url.as_str();
client
.goto(url_str)
.await
.map_err(|e| FetchError::Retryable(format!("Fantoccini failed to navigate to {}: {}", url, e)))?;
let page_source = client
.source()
.await
.map_err(|e| FetchError::Retryable(format!("Fantoccini failed to get page source: {}", e)))?;
Ok(page_source)
}
HttpClient::Chromium(browser, config, _, _) => {
let page = browser
.new_page("about:blank")
.await
.map_err(|e| FetchError::Retryable(format!("Chrome failed to open blank page: {}", e)))?;
let network_idle_listener = if config.network_idle {
match page.event_listener::<EventLifecycleEvent>().await {
Ok(events) => Some(events),
Err(e) => {
tracing::warn!("Failed to register networkIdle listener for {}: {}", url, e);
None
}
}
} else {
None
};
let result = async {
page.goto(url.as_str())
.await
.map_err(|e| format!("Chrome failed to navigate to {}: {}", url, e))?;
if let Some(mut events) = network_idle_listener {
let timeout = if config.strategy_timeout.is_zero() {
Duration::from_secs(30)
} else {
config.strategy_timeout
};
let _ = tokio::time::timeout(timeout, async {
while let Some(event) = events.next().await {
if event.name == "networkIdle" {
break;
}
}
})
.await;
}
if let Some(selector) = &config.wait_for_selector {
let timeout = if config.strategy_timeout.is_zero() {
Duration::from_secs(30)
} else {
config.strategy_timeout
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
match page.find_element(selector.clone()).await {
Ok(_) => break,
Err(_) => {
if tokio::time::Instant::now() >= deadline {
tracing::warn!("Timed out waiting for selector '{}' on {}", selector, url);
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}
}
if !config.fixed_delay.is_zero() {
tokio::time::sleep(config.fixed_delay).await;
}
page.content()
.await
.map_err(|e| format!("Chrome failed to get content from {}: {}", url, e))
}
.await;
let _ = page.close().await;
result.map_err(FetchError::Retryable)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_client_creation() {
let client = HttpClient::default();
assert!(matches!(client, HttpClient::Reqwest(_, _)));
}
#[test]
fn test_new_reqwest_client() {
let client = HttpClient::new_reqwest(30.0).unwrap();
assert!(matches!(client, HttpClient::Reqwest(_, _)));
assert_eq!(client.retry_config(), &RetryConfig::default());
}
#[test]
fn test_new_reqwest_with_basic_auth() {
let client = HttpClient::new_reqwest_with_options(
30.0,
3,
RetryConfig::default(),
HeaderMap::new(),
Some(AuthConfig::Basic {
username: "user".to_string(),
password: Some("pass".to_string()),
}),
)
.unwrap();
assert!(matches!(client, HttpClient::Reqwest(_, _)));
}
#[test]
fn test_new_reqwest_with_bearer_auth() {
let client = HttpClient::new_reqwest_with_options(
30.0,
3,
RetryConfig::default(),
HeaderMap::new(),
Some(AuthConfig::Bearer {
token: "secret-token".to_string(),
}),
)
.unwrap();
assert!(matches!(client, HttpClient::Reqwest(_, _)));
}
#[test]
fn test_retry_config_next_backoff_respects_max() {
let retry_config = RetryConfig {
max_retries: 5,
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(3),
backoff_multiplier: 2.0,
};
let backoff = retry_config.next_backoff(Duration::from_secs(1));
assert_eq!(backoff, Duration::from_secs(2));
let backoff = retry_config.next_backoff(backoff);
assert_eq!(backoff, Duration::from_secs(3));
let backoff = retry_config.next_backoff(backoff);
assert_eq!(backoff, Duration::from_secs(3));
}
#[test]
fn test_retry_config_disabled() {
let retry_config = RetryConfig::disabled();
assert_eq!(retry_config.max_retries, 0);
}
#[tokio::test]
async fn test_fetch_retries_on_server_error_then_succeeds() {
use httpmock::MockServer;
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::GET).path("/flaky");
then.status(503);
})
.await;
let retry_config = RetryConfig {
max_retries: 2,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(5),
backoff_multiplier: 2.0,
};
let client = HttpClient::new_reqwest_with_options(5.0, 3, retry_config, HeaderMap::new(), None).unwrap();
let url = Url::parse(&format!("http://{}/flaky", server.address())).unwrap();
let result = client.fetch(url).await;
assert!(result.is_err());
assert_eq!(mock.calls_async().await, 3);
}
#[tokio::test]
async fn test_fetch_does_not_retry_on_client_error() {
use httpmock::MockServer;
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::GET).path("/missing");
then.status(404);
})
.await;
let retry_config = RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(5),
backoff_multiplier: 2.0,
};
let client = HttpClient::new_reqwest_with_options(5.0, 3, retry_config, HeaderMap::new(), None).unwrap();
let url = Url::parse(&format!("http://{}/missing", server.address())).unwrap();
let result = client.fetch(url).await;
assert!(result.is_err());
assert_eq!(mock.calls_async().await, 1);
}
}