Skip to main content

millipede_http/
kind.rs

1use std::{
2    collections::hash_map::DefaultHasher,
3    fmt,
4    hash::{Hash, Hasher},
5    sync::{
6        Arc, Mutex, OnceLock,
7        atomic::{AtomicUsize, Ordering},
8    },
9    time::Duration,
10};
11
12use anyhow::anyhow;
13use futures_util::future::BoxFuture;
14use http::{HeaderValue, StatusCode, header::USER_AGENT};
15use millipede_core::{
16    antibot::{AntiBotDetector, AntiBotSignals, DefaultAntiBotDetector},
17    crawler::{
18        AttemptObservation, Crawler, CrawlerEnv, CrawlerHandle, CrawlerKind, RequestEnv,
19        RequestOutcome,
20    },
21    enqueue::EnqueueLinker,
22    errors::CrawlError,
23    events::CrawlerEvent,
24    http_client::{HttpClient, HttpClientError, HttpRequest, HttpResponse, HttpStatusError},
25    proxy::{
26        ProxyBuckets, ProxyConfiguration, ProxyInfo, ProxyKind, ProxyResolveContext,
27        ProxyRouteContext, ProxyStrategy,
28    },
29    request::Request,
30    router::HasRequest,
31    session::{Session, SessionPool, SessionPoolOptions},
32    storage::StorageHandle,
33};
34
35use crate::{CoalescingClient, ReqwestClient};
36
37/// Parses `Retry-After`, capped at ten minutes as the header-trust ceiling.
38///
39/// The core-side 429 penalty has its own separate five-minute cap.
40fn parse_retry_after(headers: &http::HeaderMap, now: time::OffsetDateTime) -> Option<Duration> {
41    const MAX_RETRY_AFTER: Duration = Duration::from_secs(600);
42
43    let value = headers
44        .get(http::header::RETRY_AFTER)?
45        .to_str()
46        .ok()?
47        .trim();
48    if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
49        let seconds = value.parse::<u64>().unwrap_or(u64::MAX);
50        return Some(Duration::from_secs(seconds.min(MAX_RETRY_AFTER.as_secs())));
51    }
52
53    let date_value = if let Some(prefix) = value.strip_suffix(" GMT") {
54        format!("{prefix} +0000")
55    } else {
56        value.to_owned()
57    };
58    let date =
59        time::OffsetDateTime::parse(&date_value, &time::format_description::well_known::Rfc2822)
60            .ok()?;
61    if date <= now {
62        return Some(Duration::ZERO);
63    }
64    let duration = Duration::try_from(date - now).ok()?;
65    Some(duration.min(MAX_RETRY_AFTER))
66}
67
68/// Per-request context produced by [`HttpKind`].
69///
70/// This intentionally differs from INTERFACE §4.2 in two ways. The response is an
71/// `Arc<HttpResponse>` because [`CrawlerKind::Context`] must be a cheap aliasing clone and the
72/// engine clones a context for each attempt. The proposed `log: Log` field is omitted because no
73/// `Log` type exists yet, logging is not scheduled by the roadmap, and Phase 2's `BasicContext`
74/// likewise omits it; use `tracing` macros in the meantime.
75///
76/// The response cookie headers from every redirect hop have already been stored in the session's
77/// cookie jar by [`ReqwestClient`] during the send. No separate cookie extraction is needed.
78#[derive(Clone)]
79#[non_exhaustive]
80pub struct HttpContext {
81    /// Crawl request that produced this context.
82    pub request: Arc<Request>,
83    /// Final buffered response, shared cheaply across context clones.
84    pub response: Arc<HttpResponse>,
85    /// Session used for this attempt, if sessions are enabled.
86    pub session: Option<Arc<Session>>,
87    /// Proxy selected for this attempt.
88    pub proxy_info: Option<ProxyInfo>,
89    /// URL enqueue helper linked to the running crawler.
90    pub enqueue: EnqueueLinker,
91    /// Open default storage resources. For example, `ctx.storage.dataset().push(&item)` works
92    /// when [`millipede_core::storage::DatasetExt`] is in scope.
93    pub storage: StorageHandle,
94    /// Weak handle back to the running crawler.
95    pub crawler: CrawlerHandle,
96}
97
98impl HasRequest for HttpContext {
99    fn request(&self) -> &Request {
100        &self.request
101    }
102}
103
104impl fmt::Debug for HttpContext {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        formatter
107            .debug_struct("HttpContext")
108            .field("request", &self.request)
109            .field("response_url", &self.response.url)
110            .field("response_status", &self.response.status)
111            .field("response_headers", &self.response.headers)
112            .field("response_body_bytes", &self.response.body.len())
113            .field("redirect_chain", &self.response.redirect_chain)
114            .field("session", &self.session)
115            .field("proxy_info", &self.proxy_info)
116            .field("enqueue", &self.enqueue)
117            .field("storage", &self.storage)
118            .field("crawler", &self.crawler)
119            .finish()
120    }
121}
122
123/// HTTP fetching behavior used by [`HttpCrawler`].
124///
125/// # End-to-end example
126///
127/// ```no_run
128/// use std::sync::Arc;
129/// use millipede_core::{crawler::Crawler, request::Request};
130/// use millipede_http::{HttpContext, HttpKind};
131/// use millipede_storage_memory::MemoryStorageClient;
132///
133/// # async fn crawl() -> Result<(), Box<dyn std::error::Error>> {
134/// let crawler = Crawler::builder(HttpKind::builder().build()?)
135///     .request_handler(|ctx: HttpContext| async move {
136///         println!("{} {}", ctx.response.status, ctx.request.url);
137///         Ok(())
138///     })
139///     .storage_client(Arc::new(MemoryStorageClient::new()))
140///     .build()
141///     .await?;
142///
143/// let stats = crawler.run([Request::get("https://example.com").build()?]).await?;
144/// assert_eq!(stats.requests_finished, 1);
145/// # Ok(())
146/// # }
147/// ```
148pub struct HttpKind {
149    client: Arc<dyn HttpClient>,
150    sessions: SessionMode,
151    proxies: ProxyBuckets,
152    proxy_strategy: Option<Arc<dyn ProxyStrategy>>,
153    user_agents: Vec<String>,
154    ua_cursor: AtomicUsize,
155    retry_status_codes: Vec<u16>,
156    retry_server_errors: bool,
157    session_status_codes: Vec<u16>,
158    request_timeout: Duration,
159    max_redirects: u32,
160    detect_anti_bot: Option<Arc<dyn AntiBotDetector>>,
161    header_generator: bool,
162    snapshot_errors: bool,
163    pre_hooks: Vec<crate::nav::HttpPreNavigationHook>,
164    post_hooks: Vec<crate::nav::HttpPostNavigationHook>,
165    storage: OnceLock<StorageHandle>,
166    persist_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
167}
168
169enum SessionMode {
170    Disabled,
171    Owned(Arc<SessionPool>),
172    Shared(Arc<SessionPool>),
173}
174
175impl HttpKind {
176    /// Starts configuring an HTTP crawler kind.
177    pub fn builder() -> HttpKindBuilder {
178        HttpKindBuilder::default()
179    }
180
181    /// Creates an HTTP kind with all defaults.
182    pub fn new() -> Result<Self, HttpClientError> {
183        Self::builder().build()
184    }
185
186    fn session_pool(&self) -> Option<&Arc<SessionPool>> {
187        match &self.sessions {
188            SessionMode::Owned(pool) | SessionMode::Shared(pool) => Some(pool),
189            SessionMode::Disabled => None,
190        }
191    }
192
193    fn classify_client_error(error: HttpClientError) -> CrawlError {
194        match error {
195            HttpClientError::Connect(_) | HttpClientError::Timeout(_) | HttpClientError::Io(_) => {
196                CrawlError::retry(error)
197            }
198            HttpClientError::Build(_)
199            | HttpClientError::InvalidRequest(_)
200            | HttpClientError::Redirect(_)
201            | HttpClientError::Decode(_)
202            | HttpClientError::Other(_) => CrawlError::non_retryable(error),
203            _ => CrawlError::non_retryable(error),
204        }
205    }
206
207    async fn classify_status(
208        &self,
209        status: StatusCode,
210        retry_after: Option<Duration>,
211        session: Option<&Arc<Session>>,
212        proxy: Option<&ProxyConfiguration>,
213        target: &url::Url,
214    ) -> Result<(), CrawlError> {
215        let status_error = |status: StatusCode| {
216            let error = HttpStatusError::new(status);
217            match retry_after {
218                Some(duration) => error.with_retry_after(duration),
219                None => error,
220            }
221        };
222        let code = status.as_u16();
223        if self.session_status_codes.contains(&code) {
224            if let Some(session) = session {
225                session.mark_bad().await;
226            }
227            if let Some(proxy) = proxy {
228                proxy.report_blocked(target);
229            }
230            return Err(CrawlError::session(status_error(status)));
231        }
232        if self.retry_status_codes.contains(&code)
233            || (self.retry_server_errors && status.is_server_error())
234        {
235            return Err(CrawlError::retry(status_error(status)));
236        }
237        if !status.is_success() && !status.is_redirection() {
238            return Err(CrawlError::non_retryable(status_error(status)));
239        }
240        if let Some(session) = session {
241            session.mark_good().await;
242        }
243        if let Some(proxy) = proxy {
244            proxy.report_success(target);
245        }
246        Ok(())
247    }
248}
249
250/// Configures [`HttpKind`].
251#[must_use = "builders do nothing unless consumed by build"]
252pub struct HttpKindBuilder {
253    http_client: Option<Arc<dyn HttpClient>>,
254    coalesce_in_flight: bool,
255    session_pool: Option<SessionPoolOptions>,
256    shared_session_pool: Option<Arc<SessionPool>>,
257    proxies: ProxyBuckets,
258    proxy_strategy: Option<Arc<dyn ProxyStrategy>>,
259    user_agents: Vec<String>,
260    retry_status_codes: Vec<u16>,
261    retry_server_errors: bool,
262    session_status_codes: Vec<u16>,
263    request_timeout: Duration,
264    max_redirects: u32,
265    detect_anti_bot: Option<Arc<dyn AntiBotDetector>>,
266    header_generator: bool,
267    snapshot_errors: bool,
268    pre_hooks: Vec<crate::nav::HttpPreNavigationHook>,
269    post_hooks: Vec<crate::nav::HttpPostNavigationHook>,
270}
271
272impl Default for HttpKindBuilder {
273    fn default() -> Self {
274        Self {
275            http_client: None,
276            coalesce_in_flight: false,
277            session_pool: Some(SessionPoolOptions::default()),
278            shared_session_pool: None,
279            proxies: ProxyBuckets::default(),
280            proxy_strategy: None,
281            user_agents: vec!["millipede/0.1 (+https://github.com/satvik007/millipede)".to_owned()],
282            retry_status_codes: vec![408, 429],
283            retry_server_errors: true,
284            session_status_codes: vec![401, 403],
285            request_timeout: Duration::from_secs(30),
286            max_redirects: 10,
287            detect_anti_bot: None,
288            header_generator: false,
289            snapshot_errors: false,
290            pre_hooks: Vec::new(),
291            post_hooks: Vec::new(),
292        }
293    }
294}
295
296impl HttpKindBuilder {
297    /// Injects the HTTP transport.
298    pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
299        self.http_client = Some(client);
300        self
301    }
302
303    /// Enables or disables optional in-flight request coalescing. It is disabled by default.
304    pub fn coalesce_in_flight(mut self, enabled: bool) -> Self {
305        self.coalesce_in_flight = enabled;
306        self
307    }
308
309    /// Enables sessions with the supplied pool options.
310    pub fn session_pool(mut self, options: SessionPoolOptions) -> Self {
311        self.session_pool = Some(options);
312        self
313    }
314
315    /// Uses an existing session pool without managing its persistence lifecycle.
316    ///
317    /// With a shared pool, the component that owns the sharing (for example, the Phase 6 smart
318    /// kind) attaches persistence exactly once. Otherwise, two kinds would double-restore and
319    /// double-persist the same pool.
320    pub fn shared_session_pool(mut self, pool: Arc<SessionPool>) -> Self {
321        self.shared_session_pool = Some(pool);
322        self
323    }
324
325    /// Disables session selection, cookie persistence, and session rotation.
326    pub fn disable_sessions(mut self) -> Self {
327        self.session_pool = None;
328        self
329    }
330
331    /// Sets the default proxy bucket.
332    pub fn proxy(mut self, proxy: ProxyConfiguration) -> Self {
333        self.proxies = std::mem::take(&mut self.proxies).with_default(proxy);
334        self
335    }
336
337    /// Replaces all logical proxy buckets.
338    pub fn proxy_buckets(mut self, proxies: ProxyBuckets) -> Self {
339        self.proxies = proxies;
340        self
341    }
342
343    /// Sets the policy that selects a proxy bucket per attempt.
344    pub fn proxy_strategy<S: ProxyStrategy>(mut self, strategy: S) -> Self {
345        self.proxy_strategy = Some(Arc::new(strategy));
346        self
347    }
348
349    /// Replaces the rotating User-Agent set.
350    pub fn user_agents<I, U>(mut self, user_agents: I) -> Self
351    where
352        I: IntoIterator<Item = U>,
353        U: Into<String>,
354    {
355        self.user_agents = user_agents.into_iter().map(Into::into).collect();
356        self
357    }
358
359    /// Replaces the exact status codes classified as ordinary retries.
360    pub fn retry_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
361        self.retry_status_codes = codes.into_iter().collect();
362        self
363    }
364
365    /// Controls whether every 5xx response is retried.
366    pub fn retry_server_errors(mut self, enabled: bool) -> Self {
367        self.retry_server_errors = enabled;
368        self
369    }
370
371    /// Replaces statuses that trigger the `retry_on_blocked` session-rotation behavior.
372    ///
373    /// The default is 401 and 403. An empty list disables status-driven session rotation.
374    pub fn session_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
375        self.session_status_codes = codes.into_iter().collect();
376        self
377    }
378
379    /// Sets the HTTP request deadline corresponding to §20.5's `navigation_timeout` option.
380    pub fn request_timeout(mut self, timeout: Duration) -> Self {
381        self.request_timeout = timeout;
382        self
383    }
384
385    /// Sets the maximum number of redirects followed for one request.
386    pub fn max_redirects(mut self, maximum: u32) -> Self {
387        self.max_redirects = maximum;
388        self
389    }
390
391    /// Enables response inspection with a caller-supplied anti-bot detector.
392    pub fn detect_anti_bot(mut self, detector: Arc<dyn AntiBotDetector>) -> Self {
393        self.detect_anti_bot = Some(detector);
394        self
395    }
396
397    /// Opts into the default anti-bot detector.
398    ///
399    /// Detection is off by default so SmartKind promotion and default crawls are unaffected.
400    pub fn detect_anti_bot_default(self) -> Self {
401        self.detect_anti_bot(Arc::new(DefaultAntiBotDetector::new()))
402    }
403
404    /// Enables or disables deterministic browser-like request headers.
405    pub fn header_generator(mut self, enabled: bool) -> Self {
406        self.header_generator = enabled;
407        self
408    }
409
410    /// Enables or disables response-body snapshots when handlers fail.
411    pub fn snapshot_errors_on_failure(mut self, enabled: bool) -> Self {
412        self.snapshot_errors = enabled;
413        self
414    }
415
416    /// Registers an HTTP hook that runs immediately before navigation.
417    pub fn pre_navigation_hook<F>(mut self, hook: F) -> Self
418    where
419        F: for<'a> Fn(
420                crate::nav::HttpPreHookCtx<'a>,
421            ) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
422            + Send
423            + Sync
424            + 'static,
425    {
426        self.pre_hooks.push(Arc::new(hook));
427        self
428    }
429
430    /// Registers an HTTP hook that runs after navigation.
431    pub fn post_navigation_hook<F>(mut self, hook: F) -> Self
432    where
433        F: for<'a> Fn(
434                crate::nav::HttpPostHookCtx<'a>,
435            ) -> futures_util::future::BoxFuture<'a, Result<(), CrawlError>>
436            + Send
437            + Sync
438            + 'static,
439    {
440        self.post_hooks.push(Arc::new(hook));
441        self
442    }
443
444    /// Builds the kind, constructing a typed-error [`ReqwestClient`] when none was injected.
445    pub fn build(self) -> Result<HttpKind, HttpClientError> {
446        let client = match self.http_client {
447            Some(client) => client,
448            None => Arc::new(ReqwestClient::new()?),
449        };
450        let client: Arc<dyn HttpClient> = if self.coalesce_in_flight {
451            Arc::new(CoalescingClient::new(client))
452        } else {
453            client
454        };
455        let sessions = if let Some(pool) = self.shared_session_pool {
456            SessionMode::Shared(pool)
457        } else if let Some(options) = self.session_pool {
458            SessionMode::Owned(Arc::new(SessionPool::new(options)))
459        } else {
460            SessionMode::Disabled
461        };
462        Ok(HttpKind {
463            client,
464            sessions,
465            proxies: self.proxies,
466            proxy_strategy: self.proxy_strategy,
467            user_agents: self.user_agents,
468            ua_cursor: AtomicUsize::new(0),
469            retry_status_codes: self.retry_status_codes,
470            retry_server_errors: self.retry_server_errors,
471            session_status_codes: self.session_status_codes,
472            request_timeout: self.request_timeout,
473            max_redirects: self.max_redirects,
474            detect_anti_bot: self.detect_anti_bot,
475            header_generator: self.header_generator,
476            snapshot_errors: self.snapshot_errors,
477            pre_hooks: self.pre_hooks,
478            post_hooks: self.post_hooks,
479            storage: OnceLock::new(),
480            persist_task: Mutex::new(None),
481        })
482    }
483}
484
485/// A crawler using [`HttpKind`] to fetch raw HTTP responses.
486pub type HttpCrawler = Crawler<HttpKind>;
487
488impl CrawlerKind for HttpKind {
489    type Context = HttpContext;
490
491    fn start<'a>(&'a self, env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
492        Box::pin(async move {
493            let client = env.storage_client().cloned().ok_or_else(|| {
494                CrawlError::non_retryable(anyhow!("HttpKind requires a storage client"))
495            })?;
496            let kvs = match env.kvs() {
497                Some(kvs) => kvs.clone(),
498                None => client
499                    .open_key_value_store(Some(env.config().default_key_value_store_id()))
500                    .await
501                    .map_err(|error| CrawlError::retry(anyhow!(error)))?,
502            };
503            let dataset = client
504                .open_dataset(Some(env.config().default_dataset_id()))
505                .await
506                .map_err(|error| CrawlError::retry(anyhow!(error)))?;
507            let queue = env.request_queue().clone();
508            let _ = self
509                .storage
510                .set(StorageHandle::new(client, dataset, kvs.clone(), queue));
511
512            if let SessionMode::Owned(pool) = &self.sessions {
513                pool.attach_persistence(kvs);
514                pool.restore().await?;
515                let pool = Arc::clone(pool);
516                let mut events = env.events().subscribe();
517                let task = tokio::spawn(async move {
518                    loop {
519                        match events.recv().await {
520                            Ok(CrawlerEvent::PersistState { .. }) => {
521                                if let Err(error) = pool.persist().await {
522                                    tracing::warn!(%error, "session pool persistence failed");
523                                }
524                            }
525                            Ok(CrawlerEvent::Exiting | CrawlerEvent::Aborting) => break,
526                            Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
527                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
528                        }
529                    }
530                });
531                *self
532                    .persist_task
533                    .lock()
534                    .unwrap_or_else(|error| error.into_inner()) = Some(task);
535            }
536            Ok(())
537        })
538    }
539
540    fn execute<'a>(
541        &'a self,
542        env: RequestEnv<'a>,
543    ) -> BoxFuture<'a, Result<Self::Context, CrawlError>> {
544        Box::pin(async move {
545            let session = if let Some(pool) = self.session_pool() {
546                Some(pool.session(None).await)
547            } else {
548                None
549            };
550            let attempt = env.request.retry_count;
551            let kind = if let Some(kind) = env.overrides.proxy_kind.clone() {
552                kind
553            } else if let Some(strategy) = &self.proxy_strategy {
554                let mut context = ProxyRouteContext::new(&env.request, attempt);
555                if let Some(profile) = env.overrides.user_agent_profile.as_deref() {
556                    context = context.previous_profile_key(profile);
557                }
558                strategy.route(&context)
559            } else {
560                ProxyKind::Default
561            };
562            let proxy_cfg = self.proxies.for_kind(&kind);
563            let resolved = if let Some(proxy_cfg) = proxy_cfg {
564                let mut context = ProxyResolveContext::new()
565                    .request(&env.request)
566                    .attempt(attempt);
567                if let Some(session) = &session {
568                    context = context.session_id(session.id());
569                }
570                proxy_cfg.new_proxy_info(context).await?
571            } else {
572                None
573            };
574            let (proxy_info, proxy_url) = match resolved {
575                Some(info) => (Some(info.clone()), Some(info.url)),
576                None => (None, None),
577            };
578
579            let mut http_request = HttpRequest::from_request(&env.request)
580                .timeout(self.request_timeout)
581                .max_redirects(self.max_redirects);
582            if let Some(session) = &session {
583                http_request = http_request.cookie_jar(session.cookie_jar().clone());
584            }
585            if let Some(proxy_url) = proxy_url {
586                http_request = http_request.proxy(proxy_url);
587            }
588
589            // A retry directive is an explicit per-attempt identity change and therefore wins even
590            // over a User-Agent header supplied by the original request.
591            if let Some(user_agent) = &env.overrides.user_agent_profile {
592                let value = HeaderValue::from_str(user_agent).map_err(|error| {
593                    CrawlError::non_retryable(HttpClientError::invalid_request(error))
594                })?;
595                http_request.headers.insert(USER_AGENT, value);
596            } else if !http_request.headers.contains_key(USER_AGENT) && !self.user_agents.is_empty()
597            {
598                let index = if let Some(session) = &session {
599                    let mut hasher = DefaultHasher::new();
600                    session.id().as_str().as_bytes().hash(&mut hasher);
601                    hasher.finish() as usize % self.user_agents.len()
602                } else {
603                    self.ua_cursor.fetch_add(1, Ordering::Relaxed) % self.user_agents.len()
604                };
605                let value = HeaderValue::from_str(&self.user_agents[index]).map_err(|error| {
606                    CrawlError::non_retryable(HttpClientError::invalid_request(error))
607                })?;
608                http_request.headers.insert(USER_AGENT, value);
609            }
610
611            if self.header_generator {
612                let token = if let Some(session) = &session {
613                    millipede_core::session::SessionToken::from(session.id())
614                } else {
615                    millipede_core::session::SessionToken::new(env.request.unique_key.clone())
616                };
617                http_request = http_request.use_header_generator(true).session_token(token);
618            }
619
620            for hook in &self.pre_hooks {
621                hook(crate::nav::HttpPreHookCtx {
622                    request: &env.request,
623                    http_request: &mut http_request,
624                    session: session.as_deref(),
625                    proxy: proxy_info.as_ref(),
626                })
627                .await?;
628            }
629
630            let response = self
631                .client
632                .send(http_request)
633                .await
634                .map_err(Self::classify_client_error)?;
635            if let Some(detector) = &self.detect_anti_bot {
636                let signals = AntiBotSignals::new(
637                    response.status,
638                    &response.headers,
639                    &response.body,
640                    &response.url,
641                );
642                if let Some(tech) = detector.detect(&signals) {
643                    if let Some(session) = &session {
644                        session.mark_bad().await;
645                    }
646                    return Err(CrawlError::AntiBotDetected {
647                        tech,
648                        source: anyhow!("response body matched a known anti-bot challenge marker"),
649                    });
650                }
651            }
652            for hook in &self.post_hooks {
653                hook(crate::nav::HttpPostHookCtx {
654                    request: &env.request,
655                    response: &response,
656                    session: session.as_deref(),
657                    proxy: proxy_info.as_ref(),
658                })
659                .await?;
660            }
661            let retry_after = parse_retry_after(&response.headers, time::OffsetDateTime::now_utc());
662            self.classify_status(
663                response.status,
664                retry_after,
665                session.as_ref(),
666                proxy_cfg,
667                &env.request.url,
668            )
669            .await?;
670            let storage =
671                self.storage.get().cloned().ok_or_else(|| {
672                    CrawlError::critical(anyhow!("HttpKind::execute before start"))
673                })?;
674            Ok(HttpContext {
675                request: env.request.clone(),
676                response: Arc::new(response),
677                session,
678                proxy_info,
679                enqueue: EnqueueLinker::new(env.crawler.clone(), &env.request),
680                storage,
681                crawler: env.crawler,
682            })
683        })
684    }
685
686    fn observe(&self, ctx: &Self::Context) -> AttemptObservation {
687        let mut observation = AttemptObservation::default();
688        observation.status = Some(ctx.response.status);
689        observation.loaded_url = Some(ctx.response.url.clone());
690        observation.session_id = ctx.session.as_ref().map(|session| session.id().clone());
691        observation.proxy_info = ctx.proxy_info.clone();
692        observation.response_bytes = Some(ctx.response.body.len());
693        observation
694    }
695
696    fn cleanup(
697        &self,
698        outcome: RequestOutcome<Self::Context>,
699    ) -> BoxFuture<'_, Result<(), CrawlError>> {
700        Box::pin(async move {
701            if let RequestOutcome::HandlerFailed { ctx, error } = outcome {
702                if error.rotates_session() {
703                    if let Some(session) = &ctx.session {
704                        session.mark_bad().await;
705                    }
706                }
707                if self.snapshot_errors {
708                    let snapshotter = millipede_core::snapshot::ErrorSnapshotter::new(
709                        ctx.storage.key_value_store().clone(),
710                    );
711                    let content_type = ctx
712                        .response
713                        .headers
714                        .get(http::header::CONTENT_TYPE)
715                        .and_then(|value| value.to_str().ok())
716                        .unwrap_or("application/octet-stream")
717                        .to_owned();
718                    if let Err(snapshot_error) = snapshotter
719                        .capture(
720                            &ctx.request,
721                            "body",
722                            ctx.response.body.clone(),
723                            &content_type,
724                        )
725                        .await
726                    {
727                        tracing::warn!(%snapshot_error, "error snapshot capture failed");
728                    }
729                }
730            }
731            Ok(())
732        })
733    }
734
735    fn stop<'a>(&'a self, _env: &'a CrawlerEnv) -> BoxFuture<'a, Result<(), CrawlError>> {
736        Box::pin(async move {
737            if let Some(task) = self
738                .persist_task
739                .lock()
740                .unwrap_or_else(|error| error.into_inner())
741                .take()
742            {
743                task.abort();
744            }
745            if let SessionMode::Owned(pool) = &self.sessions {
746                if let Err(error) = pool.persist().await {
747                    tracing::warn!(%error, "final session pool persistence failed");
748                }
749            }
750            Ok(())
751        })
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[tokio::test]
760    async fn only_success_and_redirection_statuses_are_successful() {
761        let kind = HttpKind::builder()
762            .disable_sessions()
763            .retry_status_codes([])
764            .retry_server_errors(false)
765            .build()
766            .expect("default HTTP client must build");
767        let target = url::Url::parse("https://example.com/").expect("test URL must parse");
768
769        for status in [
770            StatusCode::CONTINUE,
771            StatusCode::INTERNAL_SERVER_ERROR,
772            StatusCode::from_u16(700).expect("nonstandard status must parse"),
773        ] {
774            let error = kind
775                .classify_status(status, None, None, None, &target)
776                .await
777                .expect_err("non-2xx/3xx status must fail");
778            assert!(matches!(error, CrawlError::NonRetryable(_)));
779        }
780
781        for status in [StatusCode::OK, StatusCode::FOUND] {
782            kind.classify_status(status, None, None, None, &target)
783                .await
784                .expect("2xx/3xx status must succeed");
785        }
786    }
787
788    #[test]
789    fn retry_after_delta_seconds() {
790        let headers = http::HeaderMap::from_iter([(
791            http::header::RETRY_AFTER,
792            HeaderValue::from_static("120"),
793        )]);
794
795        assert_eq!(
796            parse_retry_after(&headers, time::OffsetDateTime::UNIX_EPOCH),
797            Some(Duration::from_secs(120))
798        );
799    }
800
801    #[test]
802    fn retry_after_overflow_is_capped() {
803        let headers = http::HeaderMap::from_iter([(
804            http::header::RETRY_AFTER,
805            HeaderValue::from_static("9999999999999999999999999999999999999999"),
806        )]);
807
808        assert_eq!(
809            parse_retry_after(&headers, time::OffsetDateTime::UNIX_EPOCH),
810            Some(Duration::from_secs(600))
811        );
812    }
813
814    #[test]
815    fn retry_after_http_date() {
816        let now = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
817            .expect("fixed timestamp must be valid");
818        let formatted = (now + time::Duration::seconds(90))
819            .format(&time::format_description::well_known::Rfc2822)
820            .expect("HTTP date must format");
821        let value = formatted
822            .strip_suffix(" +0000")
823            .map(|prefix| format!("{prefix} GMT"))
824            .expect("UTC RFC 2822 date must have a numeric zone");
825        let mut headers = http::HeaderMap::new();
826        headers.insert(
827            http::header::RETRY_AFTER,
828            HeaderValue::from_str(&value).expect("HTTP date must be a header value"),
829        );
830
831        let parsed = parse_retry_after(&headers, now).expect("HTTP date must parse");
832        assert!(parsed.abs_diff(Duration::from_secs(90)) <= Duration::from_secs(1));
833    }
834
835    #[test]
836    fn retry_after_past_date_is_zero() {
837        let now = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
838            .expect("fixed timestamp must be valid");
839        let formatted = (now - time::Duration::seconds(90))
840            .format(&time::format_description::well_known::Rfc2822)
841            .expect("HTTP date must format");
842        let value = formatted
843            .strip_suffix(" +0000")
844            .map(|prefix| format!("{prefix} GMT"))
845            .expect("UTC RFC 2822 date must have a numeric zone");
846        let mut headers = http::HeaderMap::new();
847        headers.insert(
848            http::header::RETRY_AFTER,
849            HeaderValue::from_str(&value).expect("HTTP date must be a header value"),
850        );
851
852        assert_eq!(parse_retry_after(&headers, now), Some(Duration::ZERO));
853    }
854
855    #[test]
856    fn retry_after_garbage_is_ignored() {
857        for value in [
858            HeaderValue::from_static("soon"),
859            HeaderValue::from_static(""),
860            HeaderValue::from_bytes(b"\xff").expect("opaque header bytes must be accepted"),
861        ] {
862            let headers = http::HeaderMap::from_iter([(http::header::RETRY_AFTER, value)]);
863            assert_eq!(
864                parse_retry_after(&headers, time::OffsetDateTime::UNIX_EPOCH),
865                None
866            );
867        }
868    }
869
870    #[test]
871    fn retry_after_absent_is_ignored() {
872        assert_eq!(
873            parse_retry_after(&http::HeaderMap::new(), time::OffsetDateTime::UNIX_EPOCH),
874            None
875        );
876    }
877}