Skip to main content

crawlkit_engine/
http.rs

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/// Retry policy for failed requests.
17///
18/// Controls exponential backoff behavior for retryable HTTP status codes
19/// and network errors. The backoff duration is calculated as:
20/// `initial_backoff * backoff_multiplier^attempt`, capped at `max_backoff`.
21///
22/// # Examples
23///
24/// ```rust
25/// use crawlkit_engine::http::RetryPolicy;
26/// use std::time::Duration;
27///
28/// let policy = RetryPolicy::default();
29/// assert_eq!(policy.max_retries, 3);
30/// assert!(policy.is_retryable(429));
31/// assert!(!policy.is_retryable(200));
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RetryPolicy {
35    /// Maximum number of retry attempts.
36    pub max_retries: usize,
37    /// Initial backoff duration.
38    #[serde(with = "crate::duration_ms")]
39    pub initial_backoff: Duration,
40    /// Maximum backoff duration.
41    #[serde(with = "crate::duration_ms")]
42    pub max_backoff: Duration,
43    /// Multiplier applied to backoff on each attempt.
44    pub backoff_multiplier: f64,
45    /// HTTP status codes that trigger a retry.
46    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    /// Returns the backoff duration for a given attempt number (0-indexed).
63    ///
64    /// The duration grows exponentially: `initial_backoff * multiplier^attempt`,
65    /// capped at `max_backoff`.
66    ///
67    /// # Examples
68    ///
69    /// ```rust
70    /// use crawlkit_engine::http::RetryPolicy;
71    /// use std::time::Duration;
72    ///
73    /// let policy = RetryPolicy::default();
74    /// assert_eq!(policy.backoff_duration(0), Duration::from_secs(1));
75    /// assert_eq!(policy.backoff_duration(1), Duration::from_secs(2));
76    /// assert_eq!(policy.backoff_duration(10), Duration::from_secs(30)); // capped
77    /// ```
78    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    /// Returns `true` if the given status code should trigger a retry.
86    ///
87    /// By default retries on: 429 (Too Many Requests), 500, 502, 503, 504.
88    pub fn is_retryable(&self, status: u16) -> bool {
89        self.retryable_statuses.contains(&status)
90    }
91}
92
93/// User-agent rotator that cycles through a list of user-agent strings.
94///
95/// Thread-safe rotation using atomic operations. Useful for distributing
96/// requests across multiple identity strings to avoid detection.
97///
98/// # Examples
99///
100/// ```rust
101/// use crawlkit_engine::http::UserAgentRotator;
102///
103/// let rotator = UserAgentRotator::new(vec![
104///     "bot/1.0".to_string(),
105///     "bot/2.0".to_string(),
106/// ]);
107/// assert_eq!(rotator.next(), "bot/1.0");
108/// assert_eq!(rotator.next(), "bot/2.0");
109/// assert_eq!(rotator.next(), "bot/1.0"); // wraps around
110/// ```
111#[derive(Debug)]
112pub struct UserAgentRotator {
113    agents: Vec<String>,
114    index: AtomicUsize,
115}
116
117impl UserAgentRotator {
118    /// Creates a new rotator with the given user-agent strings.
119    ///
120    /// # Panics
121    ///
122    /// Panics if `agents` is empty.
123    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    /// Returns the next user-agent string in rotation.
135    ///
136    /// Uses `AcqRel` ordering to ensure fair rotation under contention.
137    /// `Relaxed` would allow multiple threads to read the same index.
138    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    /// Returns the number of user-agents in the rotation.
144    pub fn len(&self) -> usize {
145        self.agents.len()
146    }
147
148    /// Returns `true` if the rotation contains no user-agents.
149    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/// Configuration for the HTTP client.
161///
162/// Controls timeout, redirect policy, retry behavior, connection pooling,
163/// and HTTP/2 settings. Can be constructed from a [`CrawlConfig`].
164///
165/// # Examples
166///
167/// ```rust
168/// use crawlkit_engine::{CrawlConfig, http::HttpClientConfig};
169///
170/// let config = HttpClientConfig::from(&CrawlConfig::default());
171/// assert_eq!(config.max_body_size, 10 * 1024 * 1024);
172/// ```
173#[derive(Debug, Clone)]
174pub struct HttpClientConfig {
175    /// Request timeout.
176    pub timeout: Duration,
177    /// Maximum number of redirects to follow.
178    pub max_redirects: usize,
179    /// Retry policy.
180    pub retry_policy: RetryPolicy,
181    /// User-agent rotator.
182    pub user_agent: Arc<UserAgentRotator>,
183    /// Maximum response body size in bytes (0 = unlimited).
184    pub max_body_size: usize,
185    /// Maximum number of idle connections per host.
186    pub pool_max_idle_per_host: usize,
187    /// Maximum number of idle connections across all hosts.
188    pub pool_max_idle: usize,
189    /// Whether to enable HTTP/2 prior knowledge (force h2).
190    pub http2_prior_knowledge: bool,
191    /// Whether to enable TCP keepalive.
192    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, // 10MB default
203            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
211/// An HTTP client with retry, redirect tracking, and user-agent rotation.
212///
213/// Built on top of `reqwest::Client` with additional features for web crawling:
214/// - Manual redirect following with hop recording
215/// - Exponential backoff retry for transient failures
216/// - User-agent rotation across requests
217/// - Response body size limiting
218/// - Streaming responses
219///
220/// # Examples
221///
222/// ```rust,no_run
223/// use crawlkit_engine::{CrawlConfig, HttpClient};
224/// use url::Url;
225///
226/// # async fn example() -> Result<(), crawlkit_engine::CrawlError> {
227/// let client = HttpClient::from_crawl_config(&CrawlConfig::default())?;
228/// let url = Url::parse("https://example.com")?;
229/// let result = client.fetch(&url).await?;
230/// assert_eq!(result.status_code, 200);
231/// # Ok(())
232/// # }
233/// ```
234pub struct HttpClient {
235    client: Client,
236    config: HttpClientConfig,
237}
238
239impl HttpClient {
240    /// Creates a new `HttpClient` from the given configuration.
241    ///
242    /// Builds a `reqwest::Client` with TLS, HTTP/2 multiplexing, connection
243    /// pooling, and redirect policy.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`CrawlError::RequestFailed`] if the underlying reqwest client
248    /// cannot be built (e.g., invalid TLS configuration).
249    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    /// Creates a new `HttpClient` from a `CrawlConfig`.
273    ///
274    /// Convenience method that converts the crawl config into an
275    /// [`HttpClientConfig`] and builds the client.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`CrawlError::RequestFailed`] if the client cannot be built.
280    pub fn from_crawl_config(config: &CrawlConfig) -> Result<Self, CrawlError> {
281        Self::new(HttpClientConfig::from(config))
282    }
283
284    /// Creates a new `HttpClient` optimized for high-throughput crawling.
285    ///
286    /// Enables HTTP/2, larger connection pools, and TCP keepalive.
287    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    /// Fetches a URL with retry logic and redirect tracking.
299    ///
300    /// Returns a [`FetchResult`] with the final URL, status, headers, and body.
301    /// Follows redirects manually to record each hop in the chain.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`CrawlError::RequestFailed`] on network errors after retries
306    /// are exhausted, or [`CrawlError::TooManyRedirects`] if the redirect
307    /// limit is exceeded.
308    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    /// Fetches a URL, following up to `max_hops` redirects manually.
314    ///
315    /// Each redirect hop is recorded. If the hop limit is exceeded,
316    /// [`CrawlError::TooManyRedirects`] is returned.
317    ///
318    /// # Errors
319    ///
320    /// Returns errors for network failures or exceeded redirect limits.
321    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(&current_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                                // No Location header — return the redirect response as-is
351                                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    /// Performs a single HTTP request with retry logic.
387    ///
388    /// Returns the final URL, status, headers, body text, and elapsed time.
389    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                        // Respect Retry-After header for 429
428                        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                    // Extract final_url before consuming the response body
458                    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    /// Returns a reference to the inner `reqwest::Client`.
493    pub fn inner(&self) -> &Client {
494        &self.client
495    }
496
497    /// Returns a reference to the client configuration.
498    pub fn config(&self) -> &HttpClientConfig {
499        &self.config
500    }
501
502    /// Fetches a URL and streams the response body, calling the callback with
503    /// each chunk.
504    ///
505    /// This is useful for large pages where you want to process HTML as it
506    /// arrives rather than buffering the entire response in memory.
507    ///
508    /// # Errors
509    ///
510    /// Returns errors for network failures or redirect limit exceeded.
511    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    /// Fetches a URL and returns the response as a streaming reader.
612    ///
613    /// Returns the response metadata (status, headers) and a streaming body.
614    /// The caller can read chunks from the stream via [`FetchStreamReader::next_chunk`].
615    ///
616    /// # Errors
617    ///
618    /// Returns errors for network failures.
619    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                    // Simple heuristic: stop if we've likely exceeded max body size
650                    // This is approximate since we don't track total here
651                    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
670/// A streaming HTTP response reader.
671///
672/// Read chunks from the body using the [`next_chunk`](FetchStreamReader::next_chunk) method.
673/// The stream automatically respects `max_body_size`. Can be converted into
674/// a [`FetchResult`] via [`into_fetch_result`](FetchStreamReader::into_fetch_result).
675///
676/// # Examples
677///
678/// ```rust,no_run
679/// use crawlkit_engine::{CrawlConfig, HttpClient};
680/// use url::Url;
681///
682/// # async fn example() -> Result<(), crawlkit_engine::CrawlError> {
683/// let client = HttpClient::from_crawl_config(&CrawlConfig::default())?;
684/// let url = Url::parse("https://example.com")?;
685/// let mut reader = client.fetch_reader(&url).await?;
686/// while let Some(chunk) = reader.next_chunk().await? {
687///     // process chunk
688/// }
689/// # Ok(())
690/// # }
691/// ```
692pub 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    /// Reads the next chunk of the response body.
704    ///
705    /// Returns `Ok(Some(bytes))` if data is available, `Ok(None)` if the
706    /// stream is complete, or `Err` on error. Respects `max_body_size`.
707    ///
708    /// # Errors
709    ///
710    /// Returns [`CrawlError::RequestFailed`] on network errors.
711    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    /// Reads the entire remaining body into a String.
729    ///
730    /// Convenience method that drains all remaining chunks and concatenates
731    /// them into a single UTF-8 string (lossy conversion).
732    ///
733    /// # Errors
734    ///
735    /// Returns [`CrawlError::RequestFailed`] on network errors.
736    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    /// Converts this into a [`FetchResult`] by reading the full body.
745    ///
746    /// Consumes the reader and returns the complete response including
747    /// headers, status, and body content.
748    ///
749    /// # Errors
750    ///
751    /// Returns [`CrawlError::RequestFailed`] on network errors.
752    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        // Capped at max_backoff
788        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"); // wraps around
816    }
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}