1use std::pin::Pin;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use futures::stream::Stream;
7use futures::StreamExt;
8use reqwest::header::USER_AGENT;
9use reqwest::{Client, StatusCode};
10use serde::{Deserialize, Serialize};
11use tokio::time::sleep;
12use url::Url;
13
14use crate::{CrawlConfig, CrawlError, FetchResult, RedirectHop};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RetryPolicy {
35 pub max_retries: usize,
37 #[serde(with = "crate::duration_ms")]
39 pub initial_backoff: Duration,
40 #[serde(with = "crate::duration_ms")]
42 pub max_backoff: Duration,
43 pub backoff_multiplier: f64,
45 pub retryable_statuses: Vec<u16>,
47}
48
49impl Default for RetryPolicy {
50 fn default() -> Self {
51 Self {
52 max_retries: 3,
53 initial_backoff: Duration::from_secs(1),
54 max_backoff: Duration::from_secs(30),
55 backoff_multiplier: 2.0,
56 retryable_statuses: vec![429, 500, 502, 503, 504],
57 }
58 }
59}
60
61impl RetryPolicy {
62 pub fn backoff_duration(&self, attempt: usize) -> Duration {
79 let base = self.initial_backoff.as_secs_f64();
80 let backoff = base * self.backoff_multiplier.powi(attempt as i32);
81 let capped = backoff.min(self.max_backoff.as_secs_f64());
82 Duration::from_secs_f64(capped)
83 }
84
85 pub fn is_retryable(&self, status: u16) -> bool {
89 self.retryable_statuses.contains(&status)
90 }
91}
92
93#[derive(Debug)]
112pub struct UserAgentRotator {
113 agents: Vec<String>,
114 index: AtomicUsize,
115}
116
117impl UserAgentRotator {
118 pub fn new(agents: Vec<String>) -> Self {
124 assert!(
125 !agents.is_empty(),
126 "UserAgentRotator requires at least one user-agent"
127 );
128 Self {
129 agents,
130 index: AtomicUsize::new(0),
131 }
132 }
133
134 pub fn next(&self) -> &str {
139 let idx = self.index.fetch_add(1, Ordering::AcqRel);
140 &self.agents[idx % self.agents.len()]
141 }
142
143 pub fn len(&self) -> usize {
145 self.agents.len()
146 }
147
148 pub fn is_empty(&self) -> bool {
150 self.agents.is_empty()
151 }
152}
153
154impl Default for UserAgentRotator {
155 fn default() -> Self {
156 Self::new(vec![format!("crawlkit/{}", env!("CARGO_PKG_VERSION"))])
157 }
158}
159
160#[derive(Debug, Clone)]
174pub struct HttpClientConfig {
175 pub timeout: Duration,
177 pub max_redirects: usize,
179 pub retry_policy: RetryPolicy,
181 pub user_agent: Arc<UserAgentRotator>,
183 pub max_body_size: usize,
185 pub pool_max_idle_per_host: usize,
187 pub pool_max_idle: usize,
189 pub http2_prior_knowledge: bool,
191 pub tcp_keepalive: Option<Duration>,
193}
194
195impl From<&CrawlConfig> for HttpClientConfig {
196 fn from(config: &CrawlConfig) -> Self {
197 Self {
198 timeout: config.request_timeout,
199 max_redirects: config.max_redirects,
200 retry_policy: RetryPolicy::default(),
201 user_agent: Arc::new(UserAgentRotator::new(vec![config.user_agent.clone()])),
202 max_body_size: 10 * 1024 * 1024, pool_max_idle_per_host: 16,
204 pool_max_idle: 32,
205 http2_prior_knowledge: true,
206 tcp_keepalive: Some(Duration::from_secs(60)),
207 }
208 }
209}
210
211pub struct HttpClient {
235 client: Client,
236 config: HttpClientConfig,
237}
238
239impl HttpClient {
240 pub fn new(config: HttpClientConfig) -> Result<Self, CrawlError> {
250 let mut builder = Client::builder()
251 .timeout(config.timeout)
252 .redirect(reqwest::redirect::Policy::limited(config.max_redirects))
253 .user_agent(config.user_agent.next())
254 .https_only(true)
255 .pool_max_idle_per_host(config.pool_max_idle_per_host)
256 .pool_idle_timeout(Duration::from_secs(90))
257 .connect_timeout(Duration::from_secs(10));
258
259 if config.http2_prior_knowledge {
260 builder = builder.http2_prior_knowledge();
261 }
262
263 if let Some(keepalive) = config.tcp_keepalive {
264 builder = builder.tcp_keepalive(keepalive);
265 }
266
267 let client = builder.build()?;
268
269 Ok(Self { client, config })
270 }
271
272 pub fn from_crawl_config(config: &CrawlConfig) -> Result<Self, CrawlError> {
281 Self::new(HttpClientConfig::from(config))
282 }
283
284 pub fn high_throughput(config: HttpClientConfig) -> Result<Self, CrawlError> {
288 let cfg = HttpClientConfig {
289 pool_max_idle_per_host: 64,
290 pool_max_idle: 128,
291 http2_prior_knowledge: true,
292 tcp_keepalive: Some(Duration::from_secs(60)),
293 ..config
294 };
295 Self::new(cfg)
296 }
297
298 pub async fn fetch(&self, url: &Url) -> Result<FetchResult, CrawlError> {
309 self.fetch_with_redirects(url, self.config.max_redirects)
310 .await
311 }
312
313 pub async fn fetch_with_redirects(
322 &self,
323 url: &Url,
324 max_hops: usize,
325 ) -> Result<FetchResult, CrawlError> {
326 let mut current_url = url.clone();
327 let mut hops: Vec<RedirectHop> = Vec::new();
328
329 for _ in 0..=max_hops {
330 match self.fetch_once(¤t_url).await {
331 Ok((final_url, status, headers, body, elapsed)) => {
332 if status.is_redirection() {
333 let next_url = headers
334 .iter()
335 .find(|(k, _)| k.eq_ignore_ascii_case("location"))
336 .map(|(_, v)| v.clone());
337
338 match next_url {
339 Some(loc) => {
340 let resolved = current_url.join(&loc)?;
341 hops.push(RedirectHop {
342 from: current_url.clone(),
343 to: resolved.clone(),
344 status_code: status.as_u16(),
345 });
346 current_url = resolved;
347 continue;
348 }
349 None => {
350 let body_size = body.len();
352 return Ok(FetchResult {
353 final_url,
354 status_code: status.as_u16(),
355 headers,
356 body,
357 response_time: elapsed,
358 body_size,
359 fetched_at: chrono::Utc::now(),
360 });
361 }
362 }
363 }
364
365 let body_size = body.len();
366 return Ok(FetchResult {
367 final_url,
368 status_code: status.as_u16(),
369 headers,
370 body,
371 response_time: elapsed,
372 body_size,
373 fetched_at: chrono::Utc::now(),
374 });
375 }
376 Err(CrawlError::RequestFailed(e)) => {
377 return Err(CrawlError::RequestFailed(e));
378 }
379 Err(e) => return Err(e),
380 }
381 }
382
383 Err(CrawlError::TooManyRedirects(max_hops))
384 }
385
386 async fn fetch_once(
390 &self,
391 url: &Url,
392 ) -> Result<(Url, StatusCode, Vec<(String, String)>, String, Duration), CrawlError> {
393 let mut last_error: Option<CrawlError> = None;
394 let max_retries = self.config.retry_policy.max_retries;
395
396 for attempt in 0..=max_retries {
397 let start = Instant::now();
398 let user_agent = self.config.user_agent.next();
399
400 let result = self
401 .client
402 .get(url.as_str())
403 .header(USER_AGENT, user_agent)
404 .send()
405 .await;
406
407 match result {
408 Ok(response) => {
409 let status = response.status();
410 let elapsed = start.elapsed();
411 let headers: Vec<(String, String)> = response
412 .headers()
413 .iter()
414 .map(|(k, v)| {
415 (
416 k.as_str().to_string(),
417 String::from_utf8_lossy(v.as_bytes()).to_string(),
418 )
419 })
420 .collect();
421
422 if self.config.retry_policy.is_retryable(status.as_u16())
423 && attempt < max_retries
424 {
425 let backoff = self.config.retry_policy.backoff_duration(attempt);
426
427 if status == StatusCode::TOO_MANY_REQUESTS {
429 if let Some(retry_after) = headers
430 .iter()
431 .find(|(k, _)| k.eq_ignore_ascii_case("retry-after"))
432 .and_then(|(_, v)| v.parse::<u64>().ok())
433 {
434 let wait = Duration::from_secs(retry_after).max(backoff);
435 tracing::warn!(
436 url = %url,
437 status = status.as_u16(),
438 retry_after = retry_after,
439 "429 Too Many Requests, waiting before retry"
440 );
441 sleep(wait).await;
442 continue;
443 }
444 }
445
446 tracing::warn!(
447 url = %url,
448 status = status.as_u16(),
449 attempt = attempt + 1,
450 backoff_ms = backoff.as_millis(),
451 "Retrying after retryable status"
452 );
453 sleep(backoff).await;
454 continue;
455 }
456
457 let final_url = response.url().clone();
459
460 let body = if self.config.max_body_size > 0 {
461 let bytes = response.bytes().await.map_err(CrawlError::RequestFailed)?;
462 let limited = &bytes[..bytes.len().min(self.config.max_body_size)];
463 String::from_utf8_lossy(limited).to_string()
464 } else {
465 response.text().await.map_err(CrawlError::RequestFailed)?
466 };
467
468 return Ok((final_url, status, headers, body, elapsed));
469 }
470 Err(e) => {
471 if (e.is_timeout() || e.is_connect()) && attempt < max_retries {
472 let backoff = self.config.retry_policy.backoff_duration(attempt);
473 tracing::warn!(
474 url = %url,
475 error = %e,
476 attempt = attempt + 1,
477 backoff_ms = backoff.as_millis(),
478 "Retrying after network error"
479 );
480 sleep(backoff).await;
481 last_error = Some(CrawlError::RequestFailed(e));
482 continue;
483 }
484 return Err(CrawlError::RequestFailed(e));
485 }
486 }
487 }
488
489 Err(last_error.unwrap_or(CrawlError::MaxRetriesExceeded(max_retries)))
490 }
491
492 pub fn inner(&self) -> &Client {
494 &self.client
495 }
496
497 pub fn config(&self) -> &HttpClientConfig {
499 &self.config
500 }
501
502 pub async fn fetch_stream<F>(
512 &self,
513 url: &Url,
514 mut on_chunk: F,
515 ) -> Result<FetchResult, CrawlError>
516 where
517 F: FnMut(&str) + Send,
518 {
519 let mut current_url = url.clone();
520 let mut hops: Vec<RedirectHop> = Vec::new();
521
522 for _ in 0..=self.config.max_redirects {
523 let start = Instant::now();
524 let user_agent = self.config.user_agent.next();
525
526 let response = self
527 .client
528 .get(current_url.as_str())
529 .header(USER_AGENT, user_agent)
530 .send()
531 .await
532 .map_err(CrawlError::RequestFailed)?;
533
534 let status = response.status();
535 let elapsed = start.elapsed();
536 let headers: Vec<(String, String)> = response
537 .headers()
538 .iter()
539 .map(|(k, v)| {
540 (
541 k.as_str().to_string(),
542 String::from_utf8_lossy(v.as_bytes()).to_string(),
543 )
544 })
545 .collect();
546
547 if status.is_redirection() {
548 let next_url = headers
549 .iter()
550 .find(|(k, _)| k.eq_ignore_ascii_case("location"))
551 .map(|(_, v)| v.clone());
552
553 match next_url {
554 Some(loc) => {
555 let resolved = current_url.join(&loc)?;
556 hops.push(RedirectHop {
557 from: current_url.clone(),
558 to: resolved.clone(),
559 status_code: status.as_u16(),
560 });
561 current_url = resolved;
562 continue;
563 }
564 None => {
565 let final_url = response.url().clone();
566 return Ok(FetchResult {
567 final_url,
568 status_code: status.as_u16(),
569 headers,
570 body: String::new(),
571 response_time: elapsed,
572 body_size: 0,
573 fetched_at: chrono::Utc::now(),
574 });
575 }
576 }
577 }
578
579 let final_url = response.url().clone();
580 let mut body = String::new();
581 let mut stream = response.bytes_stream();
582 let mut total_size: usize = 0;
583
584 while let Some(chunk_result) = stream.next().await {
585 let chunk = chunk_result.map_err(CrawlError::RequestFailed)?;
586 total_size += chunk.len();
587
588 if self.config.max_body_size > 0 && total_size > self.config.max_body_size {
589 break;
590 }
591
592 let chunk_str = String::from_utf8_lossy(&chunk);
593 on_chunk(&chunk_str);
594 body.push_str(&chunk_str);
595 }
596
597 return Ok(FetchResult {
598 final_url,
599 status_code: status.as_u16(),
600 headers,
601 body,
602 response_time: elapsed,
603 body_size: total_size,
604 fetched_at: chrono::Utc::now(),
605 });
606 }
607
608 Err(CrawlError::TooManyRedirects(self.config.max_redirects))
609 }
610
611 pub async fn fetch_reader(&self, url: &Url) -> Result<FetchStreamReader, CrawlError> {
620 let start = Instant::now();
621 let user_agent = self.config.user_agent.next();
622
623 let response = self
624 .client
625 .get(url.as_str())
626 .header(USER_AGENT, user_agent)
627 .send()
628 .await
629 .map_err(CrawlError::RequestFailed)?;
630
631 let status = response.status();
632 let elapsed = start.elapsed();
633 let headers: Vec<(String, String)> = response
634 .headers()
635 .iter()
636 .map(|(k, v)| {
637 (
638 k.as_str().to_string(),
639 String::from_utf8_lossy(v.as_bytes()).to_string(),
640 )
641 })
642 .collect();
643 let final_url = response.url().clone();
644 let max_body_size = self.config.max_body_size;
645
646 let stream = response.bytes_stream().take_while(move |result| {
647 let should_continue = match result {
648 Ok(_bytes) => {
649 true
652 }
653 Err(_) => false,
654 };
655 async move { should_continue }
656 });
657
658 Ok(FetchStreamReader {
659 final_url,
660 status_code: status.as_u16(),
661 headers,
662 response_time: elapsed,
663 stream: Box::pin(stream),
664 body_size: 0,
665 max_body_size,
666 })
667 }
668}
669
670pub struct FetchStreamReader {
693 pub final_url: Url,
694 pub status_code: u16,
695 pub headers: Vec<(String, String)>,
696 pub response_time: Duration,
697 stream: Pin<Box<dyn Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send>>,
698 pub body_size: usize,
699 max_body_size: usize,
700}
701
702impl FetchStreamReader {
703 pub async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, CrawlError> {
712 if self.max_body_size > 0 && self.body_size >= self.max_body_size {
713 return Ok(None);
714 }
715
716 match self.stream.next().await {
717 Some(Ok(chunk)) => {
718 let chunk: bytes::Bytes = chunk;
719 let len = chunk.len();
720 self.body_size += len;
721 Ok(Some(chunk.to_vec()))
722 }
723 Some(Err(e)) => Err(CrawlError::RequestFailed(e)),
724 None => Ok(None),
725 }
726 }
727
728 pub async fn read_body(&mut self) -> Result<String, CrawlError> {
737 let mut body = String::new();
738 while let Some(chunk) = self.next_chunk().await? {
739 body.push_str(&String::from_utf8_lossy(&chunk));
740 }
741 Ok(body)
742 }
743
744 pub async fn into_fetch_result(mut self) -> Result<FetchResult, CrawlError> {
753 let body = self.read_body().await?;
754 let body_size = self.body_size;
755 Ok(FetchResult {
756 final_url: self.final_url,
757 status_code: self.status_code,
758 headers: self.headers,
759 body,
760 response_time: self.response_time,
761 body_size,
762 fetched_at: chrono::Utc::now(),
763 })
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn test_retry_policy_default() {
773 let policy = RetryPolicy::default();
774 assert_eq!(policy.max_retries, 3);
775 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
776 assert_eq!(policy.max_backoff, Duration::from_secs(30));
777 assert!((policy.backoff_multiplier - 2.0).abs() < f64::EPSILON);
778 }
779
780 #[test]
781 fn test_retry_policy_backoff_duration() {
782 let policy = RetryPolicy::default();
783 assert_eq!(policy.backoff_duration(0), Duration::from_secs(1));
784 assert_eq!(policy.backoff_duration(1), Duration::from_secs(2));
785 assert_eq!(policy.backoff_duration(2), Duration::from_secs(4));
786 assert_eq!(policy.backoff_duration(3), Duration::from_secs(8));
787 assert_eq!(policy.backoff_duration(10), Duration::from_secs(30));
789 }
790
791 #[test]
792 fn test_retry_policy_is_retryable() {
793 let policy = RetryPolicy::default();
794 assert!(policy.is_retryable(429));
795 assert!(policy.is_retryable(500));
796 assert!(policy.is_retryable(502));
797 assert!(policy.is_retryable(503));
798 assert!(policy.is_retryable(504));
799 assert!(!policy.is_retryable(200));
800 assert!(!policy.is_retryable(404));
801 }
802
803 #[test]
804 fn test_user_agent_rotator() {
805 let rotator = UserAgentRotator::new(vec![
806 "agent-1".to_string(),
807 "agent-2".to_string(),
808 "agent-3".to_string(),
809 ]);
810 assert_eq!(rotator.len(), 3);
811 assert!(!rotator.is_empty());
812 assert_eq!(rotator.next(), "agent-1");
813 assert_eq!(rotator.next(), "agent-2");
814 assert_eq!(rotator.next(), "agent-3");
815 assert_eq!(rotator.next(), "agent-1"); }
817
818 #[test]
819 fn test_user_agent_rotator_default() {
820 let rotator = UserAgentRotator::default();
821 assert_eq!(rotator.len(), 1);
822 let agent = rotator.next().to_string();
823 assert!(agent.starts_with("crawlkit/"));
824 }
825
826 #[test]
827 fn test_http_client_config_from_crawl_config() {
828 let crawl_config = CrawlConfig::default();
829 let http_config = HttpClientConfig::from(&crawl_config);
830 assert_eq!(http_config.timeout, Duration::from_secs(30));
831 assert_eq!(http_config.max_redirects, 20);
832 assert_eq!(http_config.max_body_size, 10 * 1024 * 1024);
833 }
834
835 #[tokio::test]
836 async fn test_http_client_creation() {
837 let config = HttpClientConfig::from(&CrawlConfig::default());
838 let client = HttpClient::new(config);
839 assert!(client.is_ok());
840 }
841}