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
37fn 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#[derive(Clone)]
79#[non_exhaustive]
80pub struct HttpContext {
81 pub request: Arc<Request>,
83 pub response: Arc<HttpResponse>,
85 pub session: Option<Arc<Session>>,
87 pub proxy_info: Option<ProxyInfo>,
89 pub enqueue: EnqueueLinker,
91 pub storage: StorageHandle,
94 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
123pub 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 pub fn builder() -> HttpKindBuilder {
178 HttpKindBuilder::default()
179 }
180
181 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#[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 pub fn http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
299 self.http_client = Some(client);
300 self
301 }
302
303 pub fn coalesce_in_flight(mut self, enabled: bool) -> Self {
305 self.coalesce_in_flight = enabled;
306 self
307 }
308
309 pub fn session_pool(mut self, options: SessionPoolOptions) -> Self {
311 self.session_pool = Some(options);
312 self
313 }
314
315 pub fn shared_session_pool(mut self, pool: Arc<SessionPool>) -> Self {
321 self.shared_session_pool = Some(pool);
322 self
323 }
324
325 pub fn disable_sessions(mut self) -> Self {
327 self.session_pool = None;
328 self
329 }
330
331 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 pub fn proxy_buckets(mut self, proxies: ProxyBuckets) -> Self {
339 self.proxies = proxies;
340 self
341 }
342
343 pub fn proxy_strategy<S: ProxyStrategy>(mut self, strategy: S) -> Self {
345 self.proxy_strategy = Some(Arc::new(strategy));
346 self
347 }
348
349 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 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 pub fn retry_server_errors(mut self, enabled: bool) -> Self {
367 self.retry_server_errors = enabled;
368 self
369 }
370
371 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 pub fn request_timeout(mut self, timeout: Duration) -> Self {
381 self.request_timeout = timeout;
382 self
383 }
384
385 pub fn max_redirects(mut self, maximum: u32) -> Self {
387 self.max_redirects = maximum;
388 self
389 }
390
391 pub fn detect_anti_bot(mut self, detector: Arc<dyn AntiBotDetector>) -> Self {
393 self.detect_anti_bot = Some(detector);
394 self
395 }
396
397 pub fn detect_anti_bot_default(self) -> Self {
401 self.detect_anti_bot(Arc::new(DefaultAntiBotDetector::new()))
402 }
403
404 pub fn header_generator(mut self, enabled: bool) -> Self {
406 self.header_generator = enabled;
407 self
408 }
409
410 pub fn snapshot_errors_on_failure(mut self, enabled: bool) -> Self {
412 self.snapshot_errors = enabled;
413 self
414 }
415
416 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 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 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
485pub 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 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}