1pub mod blocklist;
37pub mod breaker;
38#[cfg(feature = "auto-browser")]
39pub mod browser;
40#[cfg(feature = "cdp")]
41pub mod browser_pool;
42#[cfg(feature = "camoufox")]
43pub mod camoufox;
44#[cfg(feature = "cdp")]
45pub mod cdp;
46#[cfg(feature = "cdp")]
47pub mod cdp_conn;
48pub mod detector;
49#[cfg(feature = "cdp")]
50pub mod health_telemetry;
51pub mod host_limiter;
52pub mod http_only;
53pub mod preference;
54pub mod traits;
55
56use crate::breaker::{
57 AttemptContext, BreakerOutcome, BreakerRegistry, Permit, ProbeGuard, classify_outcome,
58};
59use crate::preference::HostPreferences;
60use crw_core::config::{BUILTIN_UA_POOL, RendererConfig, RendererMode, StealthConfig};
61use crw_core::error::{CrwError, CrwResult};
62use crw_core::metrics::metrics;
63use crw_core::types::{
64 FailoverErrorKind, FetchResult, RenderDecision, RendererKind, resolve_render_js,
65};
66use std::collections::HashMap;
67use std::sync::Arc;
68use std::time::Duration;
69use traits::PageFetcher;
70
71tokio::task_local! {
72 pub static REQUEST_COUNTRY: Option<String>;
78}
79
80tokio::task_local! {
81 pub static REQUEST_PROXY: Option<Arc<crw_core::ProxyEntry>>;
87}
88
89#[derive(Debug, Clone, Copy)]
94pub struct ScreenshotReq {
95 pub full_page: bool,
98}
99
100tokio::task_local! {
101 pub static REQUEST_SCREENSHOT: Option<ScreenshotReq>;
107}
108
109pub fn render_reserve(pool_size: usize) -> usize {
115 if pool_size <= 1 {
116 0
117 } else {
118 (pool_size / 4).max(1).min(pool_size - 1)
119 }
120}
121
122pub fn screenshot_requested() -> bool {
125 REQUEST_SCREENSHOT
126 .try_with(|s| s.is_some())
127 .unwrap_or(false)
128}
129
130pub fn current_screenshot_req() -> Option<ScreenshotReq> {
132 REQUEST_SCREENSHOT.try_with(|s| *s).ok().flatten()
133}
134
135fn renderer_kind_for(name: &str) -> Option<RendererKind> {
139 match name {
140 "http" | "http_only_fallback" => Some(RendererKind::Http),
141 "lightpanda" => Some(RendererKind::Lightpanda),
142 "chrome" => Some(RendererKind::Chrome),
143 "chrome_proxy" => Some(RendererKind::ChromeProxy),
144 "camoufox" => Some(RendererKind::Camoufox),
145 _ => None,
146 }
147}
148
149fn classify_renderer_error(err: &CrwError) -> FailoverErrorKind {
159 match err {
160 CrwError::Timeout(_) => FailoverErrorKind::LightpandaTimeout,
161 CrwError::TargetUnreachable(_) => FailoverErrorKind::NetworkError,
162 CrwError::HttpError(_) => FailoverErrorKind::NetworkError,
163 CrwError::RendererError(_) => FailoverErrorKind::LightpandaCrash,
166 _ => FailoverErrorKind::Other,
167 }
168}
169
170fn tier_timeouts_from(
173 config: &RendererConfig,
174) -> std::collections::HashMap<RendererKind, std::time::Duration> {
175 let mut m = std::collections::HashMap::new();
176 m.insert(
177 RendererKind::Http,
178 std::time::Duration::from_millis(config.http_timeout()),
179 );
180 m.insert(
181 RendererKind::Lightpanda,
182 std::time::Duration::from_millis(config.lightpanda_timeout()),
183 );
184 m.insert(
185 RendererKind::Chrome,
186 std::time::Duration::from_millis(config.chrome_timeout()),
187 );
188 m.insert(
189 RendererKind::ChromeProxy,
190 std::time::Duration::from_millis(config.chrome_proxy_timeout()),
191 );
192 m.insert(
197 RendererKind::Camoufox,
198 std::time::Duration::from_millis(config.camoufox_timeout()),
199 );
200 m
201}
202
203fn credit_for(_kind: RendererKind) -> u32 {
209 1
210}
211
212fn stamp_http_decision(result: &mut FetchResult, requested_renderer: Option<&str>) {
216 if result.render_decision.is_some() {
217 return;
218 }
219 let kind = RendererKind::Http;
220 result.credit_cost = credit_for(kind);
221 result.render_decision = Some(match requested_renderer {
222 Some("http") => RenderDecision::UserPinned { renderer: kind },
223 _ => RenderDecision::AutoDefault { chosen: kind },
224 });
225 metrics()
227 .render_route_decision_total
228 .with_label_values(&[kind.as_str(), "success"])
229 .inc();
230}
231
232fn host_of(url: &str) -> String {
234 url::Url::parse(url)
235 .ok()
236 .and_then(|u| u.host_str().map(|h| h.to_string()))
237 .unwrap_or_default()
238}
239
240fn pick_ua<'a>(default_ua: &'a str, stealth: &'a StealthConfig) -> String {
242 if stealth.enabled {
243 let pool: &[&str] = if stealth.user_agents.is_empty() {
244 BUILTIN_UA_POOL
245 } else {
246 return stealth.user_agents[rand::random_range(0..stealth.user_agents.len())].clone();
248 };
249 pool[rand::random_range(0..pool.len())].to_string()
250 } else {
251 default_ua.to_string()
252 }
253}
254
255#[allow(dead_code)] struct JsAttemptClass {
260 text_len: usize,
261 is_placeholder: bool,
262 failed_render: Option<detector::FailedRenderReason>,
263 is_bot_wall: bool,
264 vendor_block: Option<&'static str>,
265 is_status_blocked: bool,
266 antibot: crw_extract::antibot::AntibotResult,
267 antibot_blocked: bool,
268 hard_block: bool,
270 acceptable: bool,
272}
273
274enum HedgeOutcome {
276 Accepted(FetchResult),
278 Thin(FetchResult, bool),
282}
283
284pub struct FallbackRenderer {
286 http: Arc<dyn PageFetcher>,
287 js_renderers: Vec<Arc<dyn PageFetcher>>,
288 render_js_default: Option<bool>,
290 latency_breakdown: bool,
292 auto_egress_escalation: bool,
295 chrome_hedge: bool,
297 hedge_sem: Arc<tokio::sync::Semaphore>,
302 preferences: Arc<HostPreferences>,
304 breakers: Arc<BreakerRegistry>,
306 tier_timeouts: std::collections::HashMap<RendererKind, std::time::Duration>,
310 requests_per_second: f64,
314 per_host_max_concurrent: u32,
317 per_host_interactive_reserve: u32,
321 antibot: crw_core::config::AntibotConfig,
325 proxy_rotator: Option<Arc<crw_core::ProxyRotator>>,
329 http_ua: String,
332 http_inject_stealth: bool,
333 http_timeout_ms: u64,
334 proxy_client_cache: std::sync::Mutex<std::collections::HashMap<String, Arc<dyn PageFetcher>>>,
339 #[cfg(feature = "cdp")]
342 chrome_pool: Option<Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>>,
343 #[cfg(feature = "camoufox")]
348 camoufox_in_auto: bool,
349}
350
351impl std::fmt::Debug for FallbackRenderer {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 f.debug_struct("FallbackRenderer")
354 .field("http", &self.http.name())
355 .field(
356 "js_renderers",
357 &self
358 .js_renderers
359 .iter()
360 .map(|r| r.name())
361 .collect::<Vec<_>>(),
362 )
363 .field("render_js_default", &self.render_js_default)
364 .finish()
365 }
366}
367
368impl FallbackRenderer {
369 pub fn new(
370 config: &RendererConfig,
371 user_agent: &str,
372 proxy: Option<&str>,
373 stealth: &StealthConfig,
374 ) -> CrwResult<Self> {
375 let effective_ua = pick_ua(user_agent, stealth);
376 let inject_headers = stealth.enabled && stealth.inject_headers;
377 let http_timeout_ms = config.http_timeout();
378 if let Some(p) = proxy {
381 crw_core::ProxyEntry::parse(p).map_err(CrwError::ConfigError)?;
382 }
383 let http = Arc::new(http_only::HttpFetcher::with_timeout(
384 &effective_ua,
385 proxy,
386 inject_headers,
387 std::time::Duration::from_millis(http_timeout_ms),
388 )) as Arc<dyn PageFetcher>;
389
390 #[cfg(not(feature = "cdp"))]
394 if matches!(
395 config.mode,
396 RendererMode::Lightpanda | RendererMode::Chrome | RendererMode::Playwright
397 ) {
398 return Err(CrwError::ConfigError(format!(
399 "renderer.mode = {:?} requires the 'cdp' feature, but this build was \
400 compiled without it. Rebuild with --features cdp or set mode = \"auto\"/\"none\".",
401 config.mode
402 )));
403 }
404
405 #[cfg(not(feature = "camoufox"))]
409 if matches!(config.mode, RendererMode::Camoufox) {
410 return Err(CrwError::ConfigError(
411 "renderer.mode = \"camoufox\" requires the 'camoufox' feature, but this build \
412 was compiled without it. Rebuild with --features camoufox or set mode = \
413 \"auto\"/\"none\"."
414 .into(),
415 ));
416 }
417
418 #[allow(unused_mut)]
419 let mut js_renderers: Vec<Arc<dyn PageFetcher>> = Vec::new();
420
421 if matches!(config.mode, RendererMode::None) {
422 if config.render_js_default == Some(true) {
423 tracing::warn!(
424 "render_js_default=true has no effect with mode=none; \
425 requests will fall back to HTTP via http_only_fallback"
426 );
427 }
428 return Ok(Self {
429 http,
430 js_renderers,
431 render_js_default: config.render_js_default,
432 latency_breakdown: config.latency_breakdown,
433 auto_egress_escalation: config.auto_egress_escalation,
434 chrome_hedge: config.chrome_hedge,
435 hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
436 preferences: Arc::new(HostPreferences::with_defaults()),
437 breakers: Arc::new(BreakerRegistry::with_defaults()),
438 tier_timeouts: tier_timeouts_from(config),
439 requests_per_second: 0.0,
440 per_host_max_concurrent: 1,
441 per_host_interactive_reserve: 1,
442 antibot: config.antibot.clone(),
443 proxy_rotator: None,
444 http_ua: effective_ua.clone(),
445 http_inject_stealth: inject_headers,
446 http_timeout_ms,
447 proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
448 #[cfg(feature = "cdp")]
449 chrome_pool: None,
450 #[cfg(feature = "camoufox")]
453 camoufox_in_auto: false,
454 });
455 }
456
457 #[cfg(feature = "cdp")]
458 let mut chrome_pool: Option<
459 Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>,
460 > = None;
461
462 #[cfg(feature = "cdp")]
463 {
464 let want = |m: RendererMode| -> bool {
465 matches!(config.mode, RendererMode::Auto) || config.mode == m
466 };
467
468 if want(RendererMode::Lightpanda) {
469 if let Some(lp) = &config.lightpanda {
470 js_renderers.push(Arc::new(
471 cdp::CdpRenderer::new(
472 "lightpanda",
473 &lp.ws_url,
474 config.lightpanda_timeout(),
475 config.pool_size,
476 )
477 .with_user_agent(&effective_ua),
478 ));
479 } else if matches!(config.mode, RendererMode::Lightpanda) {
480 return Err(CrwError::ConfigError(
481 "renderer.mode = \"lightpanda\" but [renderer.lightpanda] ws_url is not \
482 configured"
483 .into(),
484 ));
485 }
486 }
487 if want(RendererMode::Playwright) {
488 if let Some(pw) = &config.playwright {
489 js_renderers.push(Arc::new(
492 cdp::CdpRenderer::new(
493 "playwright",
494 &pw.ws_url,
495 config.chrome_timeout(),
496 config.pool_size,
497 )
498 .with_user_agent(&effective_ua),
499 ));
500 } else if matches!(config.mode, RendererMode::Playwright) {
501 return Err(CrwError::ConfigError(
502 "renderer.mode = \"playwright\" but [renderer.playwright] ws_url is not \
503 configured"
504 .into(),
505 ));
506 }
507 }
508 if want(RendererMode::Chrome) {
509 if let Some(ch) = &config.chrome {
510 let blocklist = blocklist::Blocklist::defaults()
511 .with_stylesheets(config.chrome_intercept_stylesheets);
512 let mut renderer = cdp::CdpRenderer::new(
513 "chrome",
514 &ch.ws_url,
515 config.chrome_timeout(),
516 config.pool_size,
517 )
518 .with_user_agent(&effective_ua)
519 .with_nav_budget(config.chrome_nav_budget_ms)
520 .with_challenge_retries(
521 config
522 .chrome_challenge_max_retries
523 .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
524 )
525 .with_spa_selector_max(
526 config
527 .chrome_spa_selector_max_ms
528 .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
529 )
530 .with_fast_ready(config.chrome_fast_ready)
531 .with_interception(
532 config.chrome_intercept_resources,
533 blocklist,
534 config.chrome_host_intercept_disable.clone(),
535 );
536
537 if config.chrome_context_pool_enabled {
541 match config.chrome_backend {
542 crw_core::config::ChromeBackend::Vanilla => {
543 let pcfg = &config.chrome_pool;
544 let size = pcfg.size.unwrap_or_else(|| {
545 let n = std::thread::available_parallelism()
546 .map(|p| p.get())
547 .unwrap_or(2);
548 std::cmp::max(2, n / 2)
549 });
550 renderer = renderer.with_pool(browser_pool::PoolCfg {
551 size,
552 recycle_after_navs: pcfg.recycle_after_navs,
553 idle_timeout: std::time::Duration::from_secs(
554 pcfg.idle_timeout_secs,
555 ),
556 health_check_after: std::time::Duration::from_secs(
557 pcfg.health_check_secs,
558 ),
559 shutdown_drain: std::time::Duration::from_secs(
560 pcfg.shutdown_drain_secs,
561 ),
562 close_target_timeout: std::time::Duration::from_secs(2),
563 dispose_ctx_timeout: std::time::Duration::from_secs(1),
564 create_ctx_timeout: std::time::Duration::from_secs(1),
565 });
566 tracing::info!(
567 pool_size = size,
568 "chrome browser-context pool enabled"
569 );
570 }
571 crw_core::config::ChromeBackend::Browserless => {
572 tracing::warn!(
573 "chrome_context_pool_enabled = true but \
574 chrome_backend = browserless — pool unsupported on \
575 this backend in v1, falling back to legacy path"
576 );
577 }
578 }
579 }
580 chrome_pool = renderer.pool();
581 js_renderers.push(Arc::new(renderer));
582 } else if matches!(config.mode, RendererMode::Chrome) {
583 return Err(CrwError::ConfigError(
584 "renderer.mode = \"chrome\" but [renderer.chrome] ws_url is not configured"
585 .into(),
586 ));
587 }
588 if let Some(cp) = config
596 .chrome_proxy
597 .as_ref()
598 .filter(|c| !c.ws_url.trim().is_empty())
599 {
600 let blocklist = blocklist::Blocklist::defaults()
601 .with_stylesheets(config.chrome_intercept_stylesheets);
602 let mut renderer = cdp::CdpRenderer::new(
603 "chrome_proxy",
604 &cp.ws_url,
605 config.chrome_proxy_timeout(),
606 config.pool_size,
607 )
608 .with_user_agent(&effective_ua)
609 .with_nav_budget(config.chrome_nav_budget_ms)
610 .with_challenge_retries(
611 config
612 .chrome_challenge_max_retries
613 .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
614 )
615 .with_spa_selector_max(
616 config
617 .chrome_spa_selector_max_ms
618 .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
619 )
620 .with_fast_ready(config.chrome_fast_ready)
621 .with_interception(
622 config.chrome_intercept_resources,
623 blocklist,
624 config.chrome_host_intercept_disable.clone(),
625 );
626 if let (Some(u), Some(p)) = (&config.proxy_base_user, &config.proxy_base_pass) {
631 renderer = renderer.with_proxy_auth_base(
632 u.clone(),
633 p.clone(),
634 config.proxy_default_country.clone(),
635 );
636 }
637 tracing::info!(
638 ws_url = %cp.ws_url,
639 proxy_auth = config.proxy_base_user.is_some(),
640 default_country = ?config.proxy_default_country,
641 "chrome_proxy tier enabled"
642 );
643 js_renderers.push(Arc::new(renderer));
644 }
645 }
646 }
647
648 #[cfg(feature = "camoufox")]
656 {
657 if let Some(cf) = config
658 .camoufox
659 .as_ref()
660 .filter(|c| !c.base_url.trim().is_empty())
661 {
662 js_renderers.push(Arc::new(camoufox::CamoufoxRenderer::new(
663 "camoufox",
664 &cf.base_url,
665 &cf.api_key,
666 config.camoufox_timeout(),
667 )) as Arc<dyn PageFetcher>);
668 tracing::info!(
669 base_url = %cf.base_url,
670 include_in_auto = cf.include_in_auto,
671 "camoufox tier enabled"
672 );
673 } else if matches!(config.mode, RendererMode::Camoufox) {
674 return Err(CrwError::ConfigError(
675 "renderer.mode = \"camoufox\" but [renderer.camoufox] base_url is not configured"
676 .into(),
677 ));
678 }
679 }
680
681 #[cfg(feature = "cdp")]
685 health_telemetry::spawn_once();
686
687 if config.render_js_default == Some(true) && js_renderers.is_empty() {
688 tracing::warn!(
689 "render_js_default=true but no JS renderer is available; \
690 requests will fall back to HTTP via http_only_fallback"
691 );
692 }
693
694 Ok(Self {
695 http,
696 js_renderers,
697 render_js_default: config.render_js_default,
698 latency_breakdown: config.latency_breakdown,
699 auto_egress_escalation: config.auto_egress_escalation,
700 chrome_hedge: config.chrome_hedge,
701 hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
702 preferences: Arc::new(HostPreferences::with_defaults()),
703 breakers: Arc::new(BreakerRegistry::with_defaults()),
704 tier_timeouts: tier_timeouts_from(config),
705 requests_per_second: 0.0,
706 per_host_max_concurrent: 1,
707 per_host_interactive_reserve: 1,
708 antibot: config.antibot.clone(),
709 proxy_rotator: None,
710 http_ua: effective_ua.clone(),
711 http_inject_stealth: inject_headers,
712 http_timeout_ms,
713 proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
714 #[cfg(feature = "cdp")]
715 chrome_pool,
716 #[cfg(feature = "camoufox")]
720 camoufox_in_auto: config.camoufox_in_ladder(),
721 })
722 }
723
724 pub fn with_proxy_rotator(
730 mut self,
731 rotator: Option<Arc<crw_core::ProxyRotator>>,
732 ) -> CrwResult<Self> {
733 self.proxy_rotator = rotator;
734 Ok(self)
735 }
736
737 fn http_fetcher_for_request(&self) -> CrwResult<Arc<dyn PageFetcher>> {
744 let Some(entry) = REQUEST_PROXY.try_with(|p| p.clone()).ok().flatten() else {
745 return Ok(self.http.clone());
746 };
747 if let Some(f) = self
749 .proxy_client_cache
750 .lock()
751 .unwrap_or_else(|e| e.into_inner())
752 .get(entry.raw())
753 .cloned()
754 {
755 return Ok(f);
756 }
757 let fetcher: Arc<dyn PageFetcher> = Arc::new(http_only::HttpFetcher::with_proxy(
758 &self.http_ua,
759 entry.raw(),
760 self.http_inject_stealth,
761 std::time::Duration::from_millis(self.http_timeout_ms),
762 )?);
763 let mut cache = self
764 .proxy_client_cache
765 .lock()
766 .unwrap_or_else(|e| e.into_inner());
767 if cache.len() >= 512 {
769 cache.clear();
770 }
771 cache.insert(entry.raw().to_string(), fetcher.clone());
772 Ok(fetcher)
773 }
774
775 pub fn pick_proxy(&self, host: Option<&str>) -> Option<Arc<crw_core::ProxyEntry>> {
780 self.proxy_rotator
781 .as_ref()
782 .map(|r| Arc::new(r.pick(host).clone()))
783 }
784
785 pub fn js_capable(&self) -> bool {
790 !self.js_renderers.is_empty() || self.auto_egress_escalation
791 }
792
793 pub fn pick_proxy_for_url(&self, url: &str) -> Option<Arc<crw_core::ProxyEntry>> {
797 self.proxy_rotator.as_ref()?;
798 let host = url::Url::parse(url)
799 .ok()
800 .and_then(|u| u.host_str().map(crate::preference::normalize_host));
801 self.pick_proxy(host.as_deref())
802 }
803
804 #[cfg(feature = "cdp")]
808 pub async fn shutdown_chrome_pool(&self, drain: std::time::Duration) {
809 if let Some(pool) = self.chrome_pool.clone() {
810 tracing::info!(
811 drain_secs = drain.as_secs(),
812 "draining chrome browser-context pool"
813 );
814 pool.shutdown(drain).await;
815 }
816 }
817
818 #[cfg(not(feature = "cdp"))]
820 pub async fn shutdown_chrome_pool(&self, _drain: std::time::Duration) {}
821
822 pub fn with_host_limits(
826 mut self,
827 requests_per_second: f64,
828 per_host_max_concurrent: u32,
829 per_host_interactive_reserve: u32,
830 ) -> Self {
831 self.requests_per_second = requests_per_second;
832 self.per_host_max_concurrent = per_host_max_concurrent;
833 self.per_host_interactive_reserve = per_host_interactive_reserve;
834 self
835 }
836
837 pub fn preferences(&self) -> Arc<HostPreferences> {
839 Arc::clone(&self.preferences)
840 }
841
842 pub fn breakers(&self) -> Arc<BreakerRegistry> {
844 Arc::clone(&self.breakers)
845 }
846
847 pub fn js_renderer_names(&self) -> Vec<&str> {
850 self.js_renderers.iter().map(|r| r.name()).collect()
851 }
852
853 pub async fn fetch(
861 &self,
862 url: &str,
863 headers: &HashMap<String, String>,
864 render_js: Option<bool>,
865 wait_for_ms: Option<u64>,
866 requested_renderer: Option<&str>,
867 deadline: crw_core::Deadline,
868 ) -> CrwResult<FetchResult> {
869 if !self.latency_breakdown {
875 return self
876 .fetch_inner(
877 url,
878 headers,
879 render_js,
880 wait_for_ms,
881 requested_renderer,
882 deadline,
883 )
884 .await;
885 }
886 let t0 = std::time::Instant::now();
887 let out = self
888 .fetch_inner(
889 url,
890 headers,
891 render_js,
892 wait_for_ms,
893 requested_renderer,
894 deadline,
895 )
896 .await;
897 let total_ms = t0.elapsed().as_millis() as u64;
898 match &out {
899 Ok(r) => tracing::info!(
900 target: "latency_breakdown",
901 url,
902 total_ms,
903 rendered_with = r.rendered_with.as_deref().unwrap_or("unknown"),
904 content_len = r.html.len(),
905 "scrape latency breakdown"
906 ),
907 Err(e) => tracing::info!(
908 target: "latency_breakdown",
909 url,
910 total_ms,
911 error = %e,
912 "scrape latency breakdown (error)"
913 ),
914 }
915 out
916 }
917
918 async fn fetch_inner(
919 &self,
920 url: &str,
921 headers: &HashMap<String, String>,
922 render_js: Option<bool>,
923 wait_for_ms: Option<u64>,
924 requested_renderer: Option<&str>,
925 deadline: crw_core::Deadline,
926 ) -> CrwResult<FetchResult> {
927 let host_key = url::Url::parse(url)
931 .ok()
932 .and_then(|u| u.host_str().map(crate::preference::normalize_host));
933 let _host_permit = if let Some(key) = host_key.as_deref() {
934 let remaining = deadline.remaining();
935 if remaining.is_zero() {
936 return Err(CrwError::Timeout(
937 deadline.overrun().as_millis().max(1) as u64
938 ));
939 }
940 match tokio::time::timeout(
941 remaining,
942 crate::host_limiter::acquire(
943 key,
944 self.requests_per_second,
945 self.per_host_max_concurrent as usize,
946 self.per_host_interactive_reserve as usize,
947 ),
948 )
949 .await
950 {
951 Ok((permit, sleep)) => {
952 if !sleep.is_zero() {
953 let budget = deadline.remaining();
954 if sleep > budget {
955 return Err(CrwError::Timeout(sleep.as_millis().max(1) as u64));
956 }
957 tokio::time::sleep(sleep).await;
958 }
959 Some(permit)
963 }
964 Err(_) => {
965 return Err(CrwError::Timeout(
966 deadline.overrun().as_millis().max(1) as u64
967 ));
968 }
969 }
970 } else {
971 None
972 };
973
974 let mut effective = resolve_render_js(render_js, self.render_js_default);
975 if effective != Some(true) && screenshot_requested() {
981 effective = Some(true);
982 }
983 tracing::debug!(
984 url,
985 request_render_js = ?render_js,
986 default_render_js = ?self.render_js_default,
987 effective_render_js = ?effective,
988 requested_renderer,
989 "FallbackRenderer::fetch dispatching"
990 );
991 let is_hard_pinned = matches!(requested_renderer, Some(name) if name != "auto");
993 match effective {
994 Some(false) => {
995 let mut r = self
996 .http_fetcher_for_request()?
997 .fetch(url, headers, None, deadline)
998 .await?;
999 stamp_http_decision(&mut r, requested_renderer);
1000 Ok(r)
1001 }
1002 Some(true) => {
1003 let mut http_result = self
1005 .http_fetcher_for_request()?
1006 .fetch(url, headers, None, deadline)
1007 .await?;
1008 if http_result.content_type.as_deref() == Some("application/pdf") {
1009 stamp_http_decision(&mut http_result, requested_renderer);
1014 return Ok(http_result);
1015 }
1016
1017 if self.js_renderers.is_empty() {
1018 if screenshot_requested() {
1022 return Err(CrwError::RendererError(
1023 "a screenshot was requested but no JS renderer is available; \
1024 configure a chrome/chrome_proxy tier"
1025 .into(),
1026 ));
1027 }
1028 tracing::warn!(
1029 url,
1030 "JS rendering requested but no renderer available — falling back to HTTP"
1031 );
1032 let mut result = http_result;
1033 result.rendered_with = Some("http_only_fallback".to_string());
1034 result.warning = Some("JS rendering was requested but no renderer is available. Content was fetched via HTTP only.".to_string());
1035 result.warnings.push(
1036 "JS rendering requested but no renderer available; HTTP fallback used"
1037 .into(),
1038 );
1039 stamp_http_decision(&mut result, requested_renderer);
1040 Ok(result)
1041 } else {
1042 self.fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1043 .await
1044 }
1045 }
1046 None => {
1047 let mut result = match self
1054 .http_fetcher_for_request()?
1055 .fetch(url, headers, None, deadline)
1056 .await
1057 {
1058 Ok(r) => r,
1059 Err(e) if !self.js_renderers.is_empty() => {
1060 tracing::info!(
1061 url,
1062 error = %e,
1063 "HTTP fetch failed, escalating to JS renderer"
1064 );
1065 return self
1066 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1067 .await
1068 .map_err(|js_err| {
1069 tracing::warn!("Both HTTP and JS failed: http={e}, js={js_err}");
1070 js_err
1071 });
1072 }
1073 Err(e) => return Err(e),
1074 };
1075
1076 if result.content_type.as_deref() == Some("application/pdf") {
1078 stamp_http_decision(&mut result, requested_renderer);
1079 return Ok(result);
1080 }
1081
1082 let needs_js = detector::needs_js_rendering(&result.html);
1083 let cf_header_signal = result.warning.as_deref() == Some("cloudflare_mitigated");
1084 let is_generic_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1085 let is_blocked = cf_header_signal
1086 || detector::looks_like_cloudflare_challenge(&result.html)
1087 || is_generic_bot_wall;
1088 let is_auth_blocked = matches!(
1101 result.status_code,
1102 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1103 );
1104 let is_2xx = (200..300).contains(&result.status_code);
1117 let is_thin_content = is_2xx
1118 && detector::looks_like_thin_html(&result.html)
1119 && detector::warrants_browser_retry(&result.html);
1120
1121 if !self.js_renderers.is_empty()
1122 && (needs_js || is_blocked || is_auth_blocked || is_thin_content)
1123 {
1124 if is_auth_blocked {
1125 tracing::info!(
1126 url,
1127 status_code = result.status_code,
1128 "HTTP {} received, escalating to JS renderer",
1129 result.status_code
1130 );
1131 } else if is_blocked {
1132 tracing::info!(
1133 url,
1134 "Anti-bot challenge detected in HTTP response, escalating to JS renderer"
1135 );
1136 if is_generic_bot_wall {
1137 tracing::info!(
1138 url,
1139 "Generic anti-bot interstitial detected, escalating to JS renderer"
1140 );
1141 }
1142 } else if needs_js {
1143 tracing::info!(url, "SPA shell detected, retrying with JS renderer");
1144 } else {
1145 tracing::info!(
1146 url,
1147 html_len = result.html.len(),
1148 "HTTP 2xx but body is thin, escalating to JS renderer"
1149 );
1150 }
1151 match self
1152 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1153 .await
1154 {
1155 Ok(js_result) => Ok(js_result),
1156 Err(e) if is_hard_pinned => {
1157 Err(e)
1160 }
1161 Err(e) => {
1162 if is_auth_blocked {
1170 tracing::error!(
1171 url,
1172 status_code = result.status_code,
1173 "JS escalation failed for soft-block status; surfacing HTTP shell with warning: {e}"
1174 );
1175 let warning = format!("js_escalation_failed: {e}");
1176 result.warning = Some(match result.warning.take() {
1177 Some(prev) => format!("{warning}; {prev}"),
1178 None => warning,
1179 });
1180 } else {
1181 tracing::warn!(
1182 "JS rendering failed, falling back to HTTP result: {e}"
1183 );
1184 }
1185 stamp_http_decision(&mut result, requested_renderer);
1186 Ok(result)
1187 }
1188 }
1189 } else {
1190 stamp_http_decision(&mut result, requested_renderer);
1191 Ok(result)
1192 }
1193 }
1194 }
1195 }
1196
1197 const MIN_RENDERED_TEXT_LEN: usize = 50;
1201
1202 fn classify_js_attempt(&self, result: &FetchResult) -> JsAttemptClass {
1207 let text_len = html_body_text_len(&result.html);
1208 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1209 let failed_render = detector::looks_like_failed_render(&result.html);
1210 let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1211 let vendor_block = detector::looks_like_vendor_block(&result.html);
1212 let is_status_blocked = matches!(
1213 result.status_code,
1214 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1215 );
1216 let antibot = if self.antibot.enabled {
1217 crw_extract::antibot::classify(Some(result.status_code), &result.html)
1218 } else {
1219 crw_extract::antibot::AntibotResult::none()
1220 };
1221 let antibot_blocked = self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1222 let hard_block = matches!(result.status_code, 401 | 403 | 429 | 503)
1224 || (520..=530).contains(&result.status_code)
1225 || is_bot_wall
1226 || vendor_block.is_some()
1227 || antibot.signal.is_blocked();
1228 let acceptable = text_len >= Self::MIN_RENDERED_TEXT_LEN
1229 && !is_placeholder
1230 && failed_render.is_none()
1231 && !is_bot_wall
1232 && vendor_block.is_none()
1233 && !is_status_blocked
1234 && !antibot_blocked;
1235 JsAttemptClass {
1236 text_len,
1237 is_placeholder,
1238 failed_render,
1239 is_bot_wall,
1240 vendor_block,
1241 is_status_blocked,
1242 antibot,
1243 antibot_blocked,
1244 hard_block,
1245 acceptable,
1246 }
1247 }
1248
1249 #[allow(clippy::too_many_arguments)] async fn try_hedge(
1264 &self,
1265 lp: &Arc<dyn PageFetcher>,
1266 chrome: &Arc<dyn PageFetcher>,
1267 url: &str,
1268 headers: &HashMap<String, String>,
1269 wait_for_ms: Option<u64>,
1270 deadline: crw_core::Deadline,
1271 host: &str,
1272 ) -> CrwResult<Option<HedgeOutcome>> {
1273 let (lp_permit, lp_guard) = self
1276 .breakers
1277 .acquire_with_guard(host, RendererKind::Lightpanda)
1278 .await;
1279 if lp_permit == Permit::Rejected {
1280 drop(lp_guard);
1281 return Ok(None);
1282 }
1283 let (ch_permit, ch_guard) = self
1284 .breakers
1285 .acquire_with_guard(host, RendererKind::Chrome)
1286 .await;
1287 if ch_permit == Permit::Rejected {
1288 drop(lp_guard);
1289 drop(ch_guard);
1290 return Ok(None);
1291 }
1292 let mut lp_guard = Some(lp_guard);
1293 let mut ch_guard = Some(ch_guard);
1294
1295 let lp_fut = lp.fetch(url, headers, wait_for_ms, deadline);
1298 let chrome_fut = chrome.fetch(url, headers, wait_for_ms, deadline);
1299 tokio::pin!(lp_fut, chrome_fut);
1300 let (mut lp_done, mut ch_done) = (false, false);
1301 let mut lp_res: Option<CrwResult<FetchResult>> = None;
1302 let mut ch_res: Option<CrwResult<FetchResult>> = None;
1303 while !(lp_done && ch_done) {
1304 tokio::select! {
1305 biased;
1306 r = &mut lp_fut, if !lp_done => {
1307 lp_done = true;
1308 let accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1309 lp_res = Some(r);
1310 if accept {
1312 break;
1313 }
1314 }
1315 r = &mut chrome_fut, if !ch_done => {
1316 ch_done = true;
1317 let ch_accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1318 ch_res = Some(r);
1319 if lp_done {
1322 let lp_accept = matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1323 if !lp_accept && ch_accept {
1324 break;
1325 }
1326 }
1327 }
1328 }
1329 }
1330 let lp_accept =
1334 matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1335 let ch_accept =
1336 matches!(&ch_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1337
1338 if lp_accept {
1340 let mut r = lp_res.unwrap().unwrap();
1341 self.record_hedge_success(host, RendererKind::Lightpanda, &r, &mut lp_guard)
1342 .await;
1343 r.credit_cost = credit_for(RendererKind::Lightpanda);
1345 r.render_decision = Some(RenderDecision::AutoDefault {
1346 chosen: RendererKind::Lightpanda,
1347 });
1348 return Ok(Some(HedgeOutcome::Accepted(r)));
1349 }
1350 let mut saw_hard_block = false;
1352 if let Some(Ok(res)) = &lp_res {
1353 let cls = self.classify_js_attempt(res);
1354 saw_hard_block |= cls.hard_block;
1355 self.record_hedge_thin(host, RendererKind::Lightpanda, &cls, &mut lp_guard)
1356 .await;
1357 }
1358 if ch_accept {
1359 let mut r = ch_res.unwrap().unwrap();
1360 self.record_hedge_success(host, RendererKind::Chrome, &r, &mut ch_guard)
1361 .await;
1362 r.credit_cost = credit_for(RendererKind::Chrome);
1363 r.render_decision = Some(RenderDecision::Failover {
1364 chain: vec![RendererKind::Lightpanda, RendererKind::Chrome],
1365 reason: FailoverErrorKind::Other,
1366 });
1367 return Ok(Some(HedgeOutcome::Accepted(r)));
1368 }
1369 if let Some(Ok(res)) = &ch_res {
1371 let cls = self.classify_js_attempt(res);
1372 saw_hard_block |= cls.hard_block;
1373 self.record_hedge_thin(host, RendererKind::Chrome, &cls, &mut ch_guard)
1374 .await;
1375 }
1376
1377 let thin = [lp_res, ch_res]
1379 .into_iter()
1380 .flatten()
1381 .filter_map(|r| r.ok())
1382 .max_by_key(|r| r.html.len());
1383 match thin {
1384 Some(r) => Ok(Some(HedgeOutcome::Thin(r, saw_hard_block))),
1385 None => Ok(None),
1388 }
1389 }
1390
1391 async fn record_hedge_success(
1393 &self,
1394 host: &str,
1395 k: RendererKind,
1396 result: &FetchResult,
1397 guard: &mut Option<ProbeGuard>,
1398 ) {
1399 if !host.is_empty() {
1400 let outcome = if result.truncated {
1401 BreakerOutcome::Truncated
1402 } else {
1403 BreakerOutcome::Success
1404 };
1405 self.breakers.record_outcome(host, k, outcome).await;
1406 self.preferences.record_success(host).await;
1407 }
1408 if let Some(g) = guard.take() {
1409 g.disarm();
1410 }
1411 }
1412
1413 async fn record_hedge_thin(
1415 &self,
1416 host: &str,
1417 k: RendererKind,
1418 cls: &JsAttemptClass,
1419 guard: &mut Option<ProbeGuard>,
1420 ) {
1421 if !host.is_empty() {
1422 self.breakers
1423 .record_outcome(host, k, BreakerOutcome::RenderError)
1424 .await;
1425 if k == RendererKind::Lightpanda {
1426 let err_kind = if cls.is_status_blocked || cls.is_bot_wall || cls.antibot_blocked {
1427 FailoverErrorKind::AntibotBlock
1428 } else {
1429 FailoverErrorKind::PlaceholderContent
1430 };
1431 let _ = self.preferences.record_failure(host, &err_kind).await;
1432 }
1433 }
1434 let _ = guard;
1436 }
1437
1438 async fn fetch_with_js(
1439 &self,
1440 url: &str,
1441 headers: &HashMap<String, String>,
1442 wait_for_ms: Option<u64>,
1443 requested_renderer: Option<&str>,
1444 deadline: crw_core::Deadline,
1445 ) -> CrwResult<FetchResult> {
1446 let host = host_of(url);
1447 let is_user_pinned = matches!(requested_renderer, Some(name) if name != "auto");
1448 if let Some(pinned) = requested_renderer
1449 && let Some(kind) = renderer_kind_for(pinned)
1450 {
1451 metrics()
1452 .user_pin_total
1453 .with_label_values(&[kind.as_str()])
1454 .inc();
1455 }
1456
1457 let mut renderers: Vec<&Arc<dyn PageFetcher>> = match requested_renderer {
1466 Some(name) if name != "auto" => self
1467 .js_renderers
1468 .iter()
1469 .filter(|r| r.name() == name)
1470 .collect(),
1471 _ => {
1472 #[cfg(feature = "camoufox")]
1473 {
1474 let in_auto = self.camoufox_in_auto;
1475 self.js_renderers
1476 .iter()
1477 .filter(|r| in_auto || r.name() != "camoufox")
1478 .collect()
1479 }
1480 #[cfg(not(feature = "camoufox"))]
1481 {
1482 self.js_renderers.iter().collect()
1483 }
1484 }
1485 };
1486
1487 let proxy_active = REQUEST_PROXY.try_with(|p| p.is_some()).unwrap_or(false);
1494 if proxy_active {
1495 renderers.retain(|r| r.name() != "lightpanda");
1496 if renderers.is_empty() {
1497 return Err(CrwError::RendererError(
1498 "a proxy is required for this request but the only available JS \
1499 renderer (lightpanda) cannot route through a proxy; configure a \
1500 chrome/chrome_proxy tier to use proxies with JS rendering"
1501 .into(),
1502 ));
1503 }
1504 }
1505
1506 if screenshot_requested() {
1514 renderers.retain(|r| r.name() != "lightpanda" && r.name() != "camoufox");
1515 if renderers.is_empty() {
1516 return Err(CrwError::RendererError(
1517 "a screenshot was requested but no CDP-capable Chrome renderer is \
1518 available; lightpanda and camoufox cannot capture screenshots — \
1519 configure a chrome/chrome_proxy tier"
1520 .into(),
1521 ));
1522 }
1523 }
1524 let auto_egress_arm: Option<Arc<dyn PageFetcher>> =
1533 if self.auto_egress_escalation && !is_user_pinned && !proxy_active {
1534 let arm = self
1535 .js_renderers
1536 .iter()
1537 .find(|r| r.name() == "chrome_proxy")
1538 .cloned();
1539 renderers.retain(|r| r.name() != "chrome_proxy");
1540 arm
1541 } else {
1542 None
1543 };
1544
1545 if !is_user_pinned
1547 && let Some(RendererKind::Chrome) = self.preferences.preferred(&host).await
1548 {
1549 renderers.sort_by_key(|r| match r.name() {
1555 "chrome" => 0,
1556 "chrome_proxy" => 1,
1557 _ => 2,
1558 });
1559 tracing::debug!(host = %host, "host promoted to chrome by preference learner");
1560 }
1561
1562 if renderers.is_empty() {
1563 let available = self.js_renderer_names();
1564 return Err(CrwError::RendererError(format!(
1565 "requested renderer '{}' not in pool [{}]",
1566 requested_renderer.unwrap_or("auto"),
1567 available.join(", ")
1568 )));
1569 }
1570
1571 let mut chain: Vec<RendererKind> = Vec::new();
1574 let mut breaker_skipped: Vec<RendererKind> = Vec::new();
1575 let mut last_error = None;
1576 let mut last_failover_reason: Option<FailoverErrorKind> = None;
1577 let mut thin_result: Option<FetchResult> = None;
1578 let mut saw_hard_block = false;
1583 let renderers_snapshot: Vec<&Arc<dyn PageFetcher>> = renderers.clone();
1588
1589 let mut hedge_done = false;
1595 if self.chrome_hedge
1596 && !is_user_pinned
1597 && !proxy_active
1598 && renderers.first().map(|r| r.name()) == Some("lightpanda")
1599 && renderers.iter().any(|r| r.name() == "chrome")
1600 && let Ok(_permit) = self.hedge_sem.clone().try_acquire_owned()
1601 {
1602 let lp = renderers
1603 .iter()
1604 .find(|r| r.name() == "lightpanda")
1605 .expect("checked above");
1606 let chrome = renderers
1607 .iter()
1608 .find(|r| r.name() == "chrome")
1609 .expect("checked above");
1610 match self
1611 .try_hedge(lp, chrome, url, headers, wait_for_ms, deadline, &host)
1612 .await
1613 {
1614 Ok(Some(HedgeOutcome::Accepted(r))) => return Ok(r),
1615 Ok(Some(HedgeOutcome::Thin(r, hb))) => {
1616 thin_result = Some(r);
1617 saw_hard_block |= hb;
1618 chain.push(RendererKind::Lightpanda);
1619 chain.push(RendererKind::Chrome);
1620 hedge_done = true;
1621 }
1622 Ok(None) => {}
1624 Err(e) => last_error = Some(e),
1625 }
1626 }
1627
1628 for renderer in renderers {
1629 if hedge_done {
1630 break;
1631 }
1632 let kind = renderer_kind_for(renderer.name());
1633
1634 let trackable = kind.filter(|_| !host.is_empty());
1637
1638 let mut probe_guard: Option<ProbeGuard> = None;
1649 if let Some(k) = trackable {
1650 let (permit, guard) = self.breakers.acquire_with_guard(&host, k).await;
1651 if permit == Permit::Rejected {
1652 tracing::info!(
1653 renderer = renderer.name(),
1654 host = %host,
1655 "circuit breaker open, skipping renderer"
1656 );
1657 metrics()
1658 .render_route_decision_total
1659 .with_label_values(&[k.as_str(), "breakerSkipped"])
1660 .inc();
1661 breaker_skipped.push(k);
1662 drop(guard); continue;
1664 }
1665 probe_guard = Some(guard);
1666 }
1667 if let Some(k) = kind {
1668 chain.push(k);
1669 }
1670
1671 let attempt_ctx = {
1674 let remaining = deadline.remaining();
1675 let tier_budget = kind
1676 .and_then(|k| self.tier_timeouts.get(&k).copied())
1677 .unwrap_or(remaining);
1678 AttemptContext::capture(remaining, tier_budget)
1679 };
1680 let attempt_start = std::time::Instant::now();
1687 let attempt_outcome = renderer.fetch(url, headers, wait_for_ms, deadline).await;
1688 if self.latency_breakdown {
1689 let attempt_ms = attempt_start.elapsed().as_millis() as u64;
1690 let tier = renderer.name();
1691 match &attempt_outcome {
1692 Ok(r) => tracing::info!(
1693 target: "latency_breakdown",
1694 url, tier, attempt_ms,
1695 status = r.status_code,
1696 html_len = r.html.len(),
1697 "hedge attempt"
1698 ),
1699 Err(e) => tracing::info!(
1700 target: "latency_breakdown",
1701 url, tier, attempt_ms,
1702 error = %e,
1703 "hedge attempt (error)"
1704 ),
1705 }
1706 }
1707 match attempt_outcome {
1708 Ok(mut result) => {
1709 let text_len = html_body_text_len(&result.html);
1710 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1711 let failed_render = detector::looks_like_failed_render(&result.html);
1712 let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1713 let vendor_block = detector::looks_like_vendor_block(&result.html);
1714 let is_status_blocked = matches!(
1718 result.status_code,
1719 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1720 );
1721 let antibot = if self.antibot.enabled {
1730 crw_extract::antibot::classify(Some(result.status_code), &result.html)
1731 } else {
1732 crw_extract::antibot::AntibotResult::none()
1733 };
1734 let antibot_blocked =
1735 self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1736 if matches!(result.status_code, 401 | 403 | 429 | 503)
1740 || (520..=530).contains(&result.status_code)
1741 || is_bot_wall
1742 || vendor_block.is_some()
1743 || antibot.signal.is_blocked()
1744 {
1745 saw_hard_block = true;
1746 }
1747 if text_len >= Self::MIN_RENDERED_TEXT_LEN
1748 && !is_placeholder
1749 && failed_render.is_none()
1750 && !is_bot_wall
1751 && vendor_block.is_none()
1752 && !is_status_blocked
1753 && !antibot_blocked
1754 {
1755 let was_promoted = matches!(
1759 self.preferences.preferred(&host).await,
1760 Some(RendererKind::Chrome)
1761 );
1762 if let Some(k) = trackable {
1763 let outcome = if result.truncated {
1766 BreakerOutcome::Truncated
1767 } else {
1768 BreakerOutcome::Success
1769 };
1770 self.breakers.record_outcome(&host, k, outcome).await;
1771 self.preferences.record_success(&host).await;
1772 metrics()
1773 .render_route_decision_total
1774 .with_label_values(&[k.as_str(), "success"])
1775 .inc();
1776 metrics()
1777 .host_preferences_size
1778 .set(self.preferences.size() as i64);
1779 }
1780 if let Some(g) = probe_guard.take() {
1781 g.disarm();
1782 }
1783 if let Some(k) = kind {
1785 result.credit_cost = credit_for(k);
1786 result.render_decision = Some(if is_user_pinned {
1787 RenderDecision::UserPinned { renderer: k }
1788 } else if !breaker_skipped.is_empty() {
1789 RenderDecision::BreakerSkipped {
1790 skipped: breaker_skipped[0],
1791 chosen: k,
1792 }
1793 } else if chain.len() > 1 {
1794 RenderDecision::Failover {
1795 chain: chain.clone(),
1796 reason: last_failover_reason
1797 .clone()
1798 .unwrap_or(FailoverErrorKind::Other),
1799 }
1800 } else if was_promoted && k == RendererKind::Chrome {
1801 RenderDecision::AutoPromoted {
1802 chosen: k,
1803 from: RendererKind::Lightpanda,
1804 reason: "host preference learner".into(),
1805 }
1806 } else {
1807 RenderDecision::AutoDefault { chosen: k }
1808 });
1809 }
1810 return Ok(result);
1811 }
1812 let err_kind = match failed_render {
1815 Some(detector::FailedRenderReason::NextJsClientError) => {
1816 FailoverErrorKind::NextJsClientError
1817 }
1818 Some(detector::FailedRenderReason::ReactMinifiedError) => {
1819 FailoverErrorKind::NextJsClientError
1820 }
1821 Some(detector::FailedRenderReason::EmptyNextRoot) => {
1822 FailoverErrorKind::EmptyNextRoot
1823 }
1824 None if vendor_block.is_some() => FailoverErrorKind::VendorBlock,
1825 None if is_status_blocked => FailoverErrorKind::StatusBlocked,
1826 None if is_placeholder => FailoverErrorKind::PlaceholderContent,
1827 None if is_bot_wall => FailoverErrorKind::PlaceholderContent,
1828 None if antibot_blocked => FailoverErrorKind::AntibotBlock,
1830 None => FailoverErrorKind::PlaceholderContent,
1831 };
1832 last_failover_reason = Some(err_kind.clone());
1833 if let Some(k) = trackable {
1834 let outcome = classify_outcome(false, false, false, &attempt_ctx);
1838 self.breakers.record_outcome(&host, k, outcome).await;
1839 if k == RendererKind::Lightpanda
1840 && let Some(target) =
1841 self.preferences.record_failure(&host, &err_kind).await
1842 {
1843 metrics()
1844 .host_preferences_promotions_total
1845 .with_label_values(&[k.as_str(), target.as_str()])
1846 .inc();
1847 tracing::info!(
1848 host = %host,
1849 "host promoted by preference learner: {} -> {}",
1850 k.as_str(),
1851 target.as_str()
1852 );
1853 }
1854 }
1855 if let Some(g) = probe_guard.take() {
1856 g.disarm();
1857 }
1858 if let Some(vendor) = vendor_block {
1859 metrics()
1860 .vendor_block_total
1861 .with_label_values(&[vendor])
1862 .inc();
1863 tracing::warn!(
1864 renderer = renderer.name(),
1865 url,
1866 vendor,
1867 "vendor anti-bot block detected"
1868 );
1869 }
1870 if antibot.signal.is_blocked() {
1873 metrics()
1874 .antibot_escalation_total
1875 .with_label_values(&[antibot.signal.class_name()])
1876 .inc();
1877 tracing::warn!(
1878 renderer = renderer.name(),
1879 url,
1880 signal = antibot.signal.class_name(),
1881 reason = %antibot.reason,
1882 status_code = result.status_code,
1883 text_len,
1884 escalated = antibot_blocked,
1885 "antibot classifier flagged a block"
1886 );
1887 }
1888 tracing::info!(
1889 renderer = renderer.name(),
1890 text_len,
1891 is_placeholder,
1892 is_bot_wall,
1893 vendor_block,
1894 is_status_blocked,
1895 antibot_signal = antibot.signal.class_name(),
1896 antibot_blocked,
1897 status_code = result.status_code,
1898 failed_render = ?failed_render,
1899 "JS renderer returned thin/placeholder/failed content, trying next renderer"
1900 );
1901 let mut annotated = result;
1908 let attempt_warning = if let Some(reason) = failed_render {
1909 format!(
1910 "{} returned a failed render ({})",
1911 renderer.name(),
1912 reason.as_str()
1913 )
1914 } else if is_placeholder {
1915 format!("{} returned a loading placeholder", renderer.name())
1916 } else if let Some(vendor) = vendor_block {
1917 format!(
1918 "{} returned a vendor anti-bot block ({vendor})",
1919 renderer.name()
1920 )
1921 } else if is_bot_wall {
1922 format!(
1923 "{} returned a generic anti-bot interstitial",
1924 renderer.name()
1925 )
1926 } else if is_status_blocked {
1927 format!(
1928 "{} returned HTTP {} (treated as blocked)",
1929 renderer.name(),
1930 annotated.status_code
1931 )
1932 } else if antibot_blocked {
1933 format!(
1934 "{} returned an anti-bot block ({}: {})",
1935 renderer.name(),
1936 antibot.signal.class_name(),
1937 antibot.reason
1938 )
1939 } else {
1940 format!(
1941 "{} returned thin content (text_len={text_len})",
1942 renderer.name()
1943 )
1944 };
1945 if is_bot_wall || vendor_block.is_some() || is_status_blocked || antibot_blocked
1946 {
1947 let msg = if let Some(v) = vendor_block {
1956 format!("{} returned a vendor anti-bot block ({v})", renderer.name())
1957 } else if is_status_blocked {
1958 format!(
1959 "{} returned HTTP {} (treated as blocked)",
1960 renderer.name(),
1961 annotated.status_code
1962 )
1963 } else if is_bot_wall {
1964 format!(
1965 "{} returned a generic anti-bot interstitial",
1966 renderer.name()
1967 )
1968 } else {
1969 format!(
1970 "{} returned an anti-bot block ({}: {})",
1971 renderer.name(),
1972 antibot.signal.class_name(),
1973 antibot.reason
1974 )
1975 };
1976 last_error = Some(CrwError::RendererError(msg));
1977 }
1978 annotated.warnings.push(attempt_warning.clone());
1979 annotated.warning = Some(match annotated.warning {
1980 Some(prev) => format!("{prev}; {attempt_warning}"),
1981 None => attempt_warning.clone(),
1982 });
1983 thin_result = Some(match thin_result {
1984 None => annotated,
1985 Some(existing) => {
1986 let (mut keeper, dropped) =
1993 if annotated.html.len() > existing.html.len() {
1994 (annotated, existing)
1995 } else {
1996 (existing, annotated)
1997 };
1998 keeper.warnings.push(attempt_warning.clone());
1999 keeper.warning = Some(match keeper.warning {
2000 Some(prev) => format!("{prev}; {attempt_warning}"),
2001 None => attempt_warning,
2002 });
2003 for w in dropped.warnings {
2006 if !keeper.warnings.contains(&w) {
2007 keeper.warnings.push(w);
2008 }
2009 }
2010 keeper
2011 }
2012 });
2013 }
2014 Err(e) => {
2015 tracing::warn!(renderer = renderer.name(), "JS renderer failed: {e}");
2016 let err_kind = classify_renderer_error(&e);
2017 last_failover_reason = Some(err_kind.clone());
2018 if let Some(k) = trackable {
2019 let was_timeout = matches!(e, CrwError::Timeout(_));
2020 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
2021 self.breakers.record_outcome(&host, k, outcome).await;
2022 if k == RendererKind::Lightpanda {
2023 let _ = self.preferences.record_failure(&host, &err_kind).await;
2024 }
2025 }
2026 if let Some(g) = probe_guard.take() {
2027 g.disarm();
2028 }
2029 last_error = Some(e);
2030 continue;
2031 }
2032 }
2033 }
2034 const LEAK_MIN_BUDGET: Duration = Duration::from_millis(500);
2052 if thin_result.is_none()
2053 && !breaker_skipped.is_empty()
2054 && !is_user_pinned
2055 && deadline.remaining() >= LEAK_MIN_BUDGET
2056 {
2057 for renderer in &renderers_snapshot {
2058 let kind = renderer_kind_for(renderer.name());
2059 let trackable = kind.filter(|_| !host.is_empty());
2060 let Some(k) = trackable else { continue };
2061 if !breaker_skipped.contains(&k) {
2062 continue;
2063 }
2064 let permit = self.breakers.try_acquire_host_only(&host, k).await;
2065 if permit == Permit::Rejected {
2066 continue;
2067 }
2068 tracing::info!(
2069 renderer = renderer.name(),
2070 host = %host,
2071 "global breaker open, host clean — leaking through one attempt"
2072 );
2073 metrics()
2074 .render_route_decision_total
2075 .with_label_values(&[k.as_str(), "leakThrough"])
2076 .inc();
2077 let attempt_ctx = {
2078 let remaining = deadline.remaining();
2079 let tier_budget = self.tier_timeouts.get(&k).copied().unwrap_or(remaining);
2080 AttemptContext::capture(remaining, tier_budget)
2081 };
2082 let res = renderer.fetch(url, headers, wait_for_ms, deadline).await;
2083 match res {
2084 Ok(mut result) => {
2085 let text_len = html_body_text_len(&result.html);
2086 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
2087 let failed_render = detector::looks_like_failed_render(&result.html);
2088 let truncated = result.truncated;
2089 let content_ok = text_len >= Self::MIN_RENDERED_TEXT_LEN
2090 && !is_placeholder
2091 && failed_render.is_none();
2092 let outcome = classify_outcome(content_ok, truncated, false, &attempt_ctx);
2093 self.breakers
2096 .record_scoped_outcome(&host, k, None, Some(outcome))
2097 .await;
2098 if content_ok {
2099 result.credit_cost = credit_for(k);
2100 result.render_decision =
2101 Some(RenderDecision::AutoDefault { chosen: k });
2102 return Ok(result);
2103 }
2104 last_error = Some(CrwError::RendererError(format!(
2107 "leak attempt on {} returned thin content (text_len={text_len})",
2108 renderer.name()
2109 )));
2110 break;
2111 }
2112 Err(e) => {
2113 let was_timeout = matches!(e, CrwError::Timeout(_));
2114 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
2115 self.breakers
2116 .record_scoped_outcome(&host, k, None, Some(outcome))
2117 .await;
2118 last_error = Some(e);
2119 break;
2120 }
2121 }
2122 }
2123 }
2124
2125 if let Some(arm) = auto_egress_arm {
2132 let kind = RendererKind::ChromeProxy;
2133 let tier_budget = self
2134 .tier_timeouts
2135 .get(&kind)
2136 .copied()
2137 .unwrap_or_else(|| std::time::Duration::from_secs(30));
2138 if saw_hard_block && deadline.remaining() >= tier_budget {
2139 chain.push(kind);
2140 let entry = self.pick_proxy_for_url(url);
2141 let attempt = REQUEST_PROXY
2142 .scope(entry, arm.fetch(url, headers, wait_for_ms, deadline))
2143 .await;
2144 match attempt {
2145 Ok(r) => {
2146 let r_text = html_body_text_len(&r.html);
2147 let r_ok = r_text >= Self::MIN_RENDERED_TEXT_LEN
2148 && detector::looks_like_failed_render(&r.html).is_none()
2149 && !detector::looks_like_loading_placeholder(&r.html);
2150 if !host.is_empty() {
2151 let outcome = if r_ok {
2152 BreakerOutcome::Success
2153 } else {
2154 BreakerOutcome::RenderError
2155 };
2156 self.breakers.record_outcome(&host, kind, outcome).await;
2157 }
2158 let better = r_ok
2165 && match &thin_result {
2166 Some(prev) => r.html.len() > prev.html.len(),
2167 None => true,
2168 };
2169 if self.latency_breakdown {
2170 tracing::info!(
2171 target: "latency_breakdown",
2172 url, tier = "chrome_proxy",
2173 ok = r_ok, consumed = better,
2174 "auto_egress fired"
2175 );
2176 }
2177 if better {
2178 thin_result = Some(r);
2179 }
2180 }
2181 Err(e) => {
2182 if !host.is_empty() {
2183 self.breakers
2184 .record_outcome(&host, kind, BreakerOutcome::ConnectionError)
2185 .await;
2186 }
2187 if self.latency_breakdown {
2188 tracing::info!(
2189 target: "latency_breakdown",
2190 url, tier = "chrome_proxy", error = %e,
2191 "auto_egress fired (error)"
2192 );
2193 }
2194 }
2195 }
2196 }
2197 }
2198
2199 if let Some(mut result) = thin_result {
2201 if let Some(last) = chain.last().copied() {
2204 result.credit_cost = credit_for(last);
2205 result.render_decision = Some(RenderDecision::Failover {
2206 chain: chain.clone(),
2207 reason: last_failover_reason
2208 .clone()
2209 .unwrap_or(FailoverErrorKind::Other),
2210 });
2211 }
2212 if is_user_pinned
2217 && chain.len() == 1
2218 && let Some(pinned) = chain.first().copied()
2219 {
2220 let reason = last_failover_reason
2221 .as_ref()
2222 .map(|r| r.as_str())
2223 .unwrap_or("unknown");
2224 let hint = format!(
2225 "Pinned renderer '{}' returned a failed render ({}). Content may be unreliable. Retry with renderer=\"chrome\" or omit the renderer field for auto-failover.",
2226 pinned.as_str(),
2227 reason,
2228 );
2229 result.warnings.push(hint);
2230 }
2231 Ok(result)
2232 } else {
2233 Err(last_error
2234 .unwrap_or_else(|| CrwError::RendererError("No JS renderer available".to_string())))
2235 }
2236 }
2237
2238 pub async fn check_health(&self) -> HashMap<String, bool> {
2240 let mut health = HashMap::new();
2241 health.insert("http".to_string(), self.http.is_available().await);
2242 for r in &self.js_renderers {
2243 health.insert(r.name().to_string(), r.is_available().await);
2244 }
2245 health
2246 }
2247}
2248
2249fn html_body_text_len(html: &str) -> usize {
2253 let body = if let Some(start) = html.find("<body") {
2255 let start = html[start..].find('>').map(|i| start + i + 1).unwrap_or(0);
2256 let end = html.find("</body>").unwrap_or(html.len());
2257 &html[start..end]
2258 } else {
2259 html
2260 };
2261 let mut in_tag = false;
2263 let mut text_len = 0;
2264 let mut prev_ws = true;
2265 for ch in body.chars() {
2266 if ch == '<' {
2267 in_tag = true;
2268 } else if ch == '>' {
2269 in_tag = false;
2270 } else if !in_tag {
2271 if ch.is_whitespace() {
2272 if !prev_ws {
2273 text_len += 1;
2274 prev_ws = true;
2275 }
2276 } else {
2277 text_len += 1;
2278 prev_ws = false;
2279 }
2280 }
2281 }
2282 text_len
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287 use super::*;
2288 use crate::breaker::BreakerConfig;
2289 #[cfg(feature = "camoufox")]
2290 use crw_core::config::CamoufoxEndpoint;
2291 #[cfg(feature = "cdp")]
2292 use crw_core::config::CdpEndpoint;
2293 use std::time::Duration;
2294
2295 fn tdl() -> crw_core::Deadline {
2297 crw_core::Deadline::now_plus(Duration::from_secs(60))
2298 }
2299
2300 fn base_cfg(mode: RendererMode) -> RendererConfig {
2301 RendererConfig {
2302 mode,
2303 ..Default::default()
2304 }
2305 }
2306
2307 #[test]
2308 fn new_mode_none_ok_no_js_renderers() {
2309 let cfg = base_cfg(RendererMode::None);
2310 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2311 assert!(r.js_renderer_names().is_empty());
2312 assert_eq!(r.render_js_default, None);
2313 }
2314
2315 #[test]
2316 fn new_mode_auto_no_endpoints_ok_http_only() {
2317 let cfg = base_cfg(RendererMode::Auto);
2318 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2319 assert!(r.js_renderer_names().is_empty());
2320 }
2321
2322 #[cfg(feature = "cdp")]
2323 #[test]
2324 fn new_mode_chrome_without_endpoint_errors() {
2325 let cfg = base_cfg(RendererMode::Chrome);
2326 let err =
2327 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2328 let msg = err.to_string().to_lowercase();
2329 assert!(msg.contains("chrome"), "expected chrome in error: {msg}");
2330 assert!(
2331 msg.contains("ws_url") || msg.contains("not configured"),
2332 "expected ws_url hint in error: {msg}"
2333 );
2334 }
2335
2336 #[cfg(feature = "cdp")]
2337 #[test]
2338 fn new_mode_chrome_with_endpoint_ok_only_chrome() {
2339 let cfg = RendererConfig {
2340 mode: RendererMode::Chrome,
2341 chrome: Some(CdpEndpoint {
2342 ws_url: "ws://127.0.0.1:9222/".into(),
2343 }),
2344 lightpanda: Some(CdpEndpoint {
2345 ws_url: "ws://127.0.0.1:9223/".into(),
2346 }),
2347 ..Default::default()
2348 };
2349 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2350 assert_eq!(r.js_renderer_names(), vec!["chrome"]);
2351 }
2352
2353 #[cfg(feature = "cdp")]
2354 #[test]
2355 fn new_mode_lightpanda_without_endpoint_errors() {
2356 let cfg = base_cfg(RendererMode::Lightpanda);
2357 let err =
2358 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2359 assert!(err.to_string().to_lowercase().contains("lightpanda"));
2360 }
2361
2362 #[cfg(feature = "cdp")]
2363 #[test]
2364 fn new_mode_auto_with_both_endpoints_preserves_order() {
2365 let cfg = RendererConfig {
2366 mode: RendererMode::Auto,
2367 lightpanda: Some(CdpEndpoint {
2368 ws_url: "ws://127.0.0.1:9222/".into(),
2369 }),
2370 chrome: Some(CdpEndpoint {
2371 ws_url: "ws://127.0.0.1:9223/".into(),
2372 }),
2373 ..Default::default()
2374 };
2375 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2376 assert_eq!(r.js_renderer_names(), vec!["lightpanda", "chrome"]);
2377 }
2378
2379 #[cfg(feature = "cdp")]
2380 #[test]
2381 fn ladder_includes_chrome_proxy_when_configured() {
2382 let cfg = RendererConfig {
2383 mode: RendererMode::Auto,
2384 lightpanda: Some(CdpEndpoint {
2385 ws_url: "ws://127.0.0.1:9222/".into(),
2386 }),
2387 chrome: Some(CdpEndpoint {
2388 ws_url: "ws://127.0.0.1:9223/".into(),
2389 }),
2390 chrome_proxy: Some(CdpEndpoint {
2391 ws_url: "ws://127.0.0.1:9224/".into(),
2392 }),
2393 ..Default::default()
2394 };
2395 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2396 assert_eq!(
2399 r.js_renderer_names(),
2400 vec!["lightpanda", "chrome", "chrome_proxy"]
2401 );
2402 }
2403
2404 #[cfg(feature = "cdp")]
2405 #[test]
2406 fn ladder_omits_chrome_proxy_when_not_configured() {
2407 let cfg = RendererConfig {
2408 mode: RendererMode::Auto,
2409 chrome: Some(CdpEndpoint {
2410 ws_url: "ws://127.0.0.1:9223/".into(),
2411 }),
2412 chrome_proxy: None,
2413 ..Default::default()
2414 };
2415 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2416 assert!(!r.js_renderer_names().contains(&"chrome_proxy"));
2417 }
2418
2419 #[cfg(not(feature = "cdp"))]
2420 #[test]
2421 fn new_mode_chrome_errors_without_cdp_feature() {
2422 let cfg = base_cfg(RendererMode::Chrome);
2423 let err =
2424 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2425 let msg = err.to_string().to_lowercase();
2426 assert!(msg.contains("cdp"), "expected cdp in error: {msg}");
2427 }
2428
2429 #[cfg(feature = "camoufox")]
2430 fn camoufox_cfg(mode: RendererMode, include_in_auto: bool) -> RendererConfig {
2431 RendererConfig {
2432 mode,
2433 camoufox: Some(CamoufoxEndpoint {
2434 base_url: "http://127.0.0.1:9377".into(),
2435 api_key: String::new(),
2436 include_in_auto,
2437 }),
2438 ..Default::default()
2439 }
2440 }
2441
2442 #[cfg(feature = "camoufox")]
2446 #[test]
2447 fn camoufox_constructed_for_pin_but_excluded_from_auto() {
2448 let cfg = camoufox_cfg(RendererMode::Auto, false);
2449 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2450 assert!(
2451 r.js_renderer_names().contains(&"camoufox"),
2452 "configured camoufox must be constructed for pin-reachability"
2453 );
2454 assert!(
2455 !r.camoufox_in_auto,
2456 "include_in_auto=false must keep camoufox out of the auto ladder"
2457 );
2458 }
2459
2460 #[cfg(feature = "camoufox")]
2461 #[test]
2462 fn camoufox_joins_auto_when_include_in_auto_true() {
2463 let cfg = camoufox_cfg(RendererMode::Auto, true);
2464 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2465 assert!(r.js_renderer_names().contains(&"camoufox"));
2466 assert!(r.camoufox_in_auto);
2467 }
2468
2469 #[cfg(feature = "camoufox")]
2472 #[test]
2473 fn camoufox_pinned_mode_uses_only_camoufox() {
2474 let cfg = camoufox_cfg(RendererMode::Camoufox, false);
2475 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2476 assert_eq!(r.js_renderer_names(), vec!["camoufox"]);
2477 assert!(r.camoufox_in_auto);
2478 }
2479
2480 #[cfg(feature = "camoufox")]
2481 #[test]
2482 fn camoufox_pinned_mode_without_base_url_errors() {
2483 let cfg = RendererConfig {
2484 mode: RendererMode::Camoufox,
2485 camoufox: Some(CamoufoxEndpoint::default()), ..Default::default()
2487 };
2488 let err =
2489 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2490 assert!(err.to_string().to_lowercase().contains("camoufox"));
2491 }
2492
2493 #[cfg(feature = "camoufox")]
2494 #[test]
2495 fn camoufox_absent_when_not_configured() {
2496 let cfg = base_cfg(RendererMode::Auto);
2497 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2498 assert!(!r.js_renderer_names().contains(&"camoufox"));
2499 assert!(!r.camoufox_in_auto);
2500 }
2501
2502 #[test]
2503 fn new_render_js_default_stored() {
2504 let cfg = RendererConfig {
2505 mode: RendererMode::None,
2506 render_js_default: Some(true),
2507 ..Default::default()
2508 };
2509 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2510 assert_eq!(r.render_js_default, Some(true));
2511 }
2512
2513 struct MockFetcher {
2515 name: &'static str,
2516 behavior: MockBehavior,
2517 }
2518
2519 #[derive(Clone)]
2520 enum MockBehavior {
2521 Ok(String),
2522 OkStatus(u16, String),
2523 Err(String),
2524 }
2525
2526 #[async_trait::async_trait]
2527 impl PageFetcher for MockFetcher {
2528 async fn fetch(
2529 &self,
2530 url: &str,
2531 _headers: &HashMap<String, String>,
2532 _wait_for_ms: Option<u64>,
2533 _deadline: crw_core::Deadline,
2534 ) -> CrwResult<FetchResult> {
2535 let (status, html) = match &self.behavior {
2536 MockBehavior::Ok(html) => (200u16, html.clone()),
2537 MockBehavior::OkStatus(s, html) => (*s, html.clone()),
2538 MockBehavior::Err(msg) => return Err(CrwError::RendererError(msg.clone())),
2539 };
2540 Ok(FetchResult {
2541 url: url.to_string(),
2542 final_url: None,
2543 status_code: status,
2544 html,
2545 content_type: Some("text/html".to_string()),
2546 raw_bytes: None,
2547 rendered_with: Some(self.name.to_string()),
2548 elapsed_ms: 0,
2549 warning: None,
2550 render_decision: None,
2551 credit_cost: 0,
2552 warnings: Vec::new(),
2553 truncated: false,
2554 deadline_exceeded: false,
2555 captured_responses: Vec::new(),
2556 screenshot: None,
2557 })
2558 }
2559
2560 fn name(&self) -> &str {
2561 self.name
2562 }
2563 fn supports_js(&self) -> bool {
2564 true
2565 }
2566 async fn is_available(&self) -> bool {
2567 true
2568 }
2569 }
2570
2571 fn rich_html(marker: &str) -> String {
2572 format!(
2573 "<html><body><article>{}{}</article></body></html>",
2574 marker,
2575 "x".repeat(200)
2576 )
2577 }
2578
2579 fn make_renderer_with_mocks(mocks: Vec<Arc<dyn PageFetcher>>) -> FallbackRenderer {
2580 let cfg = base_cfg(RendererMode::None);
2582 let mut r =
2583 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2584 r.js_renderers = mocks;
2585 r
2586 }
2587
2588 #[tokio::test]
2589 async fn proxy_active_lightpanda_only_fails_closed() {
2590 let lp = Arc::new(MockFetcher {
2593 name: "lightpanda",
2594 behavior: MockBehavior::Ok(rich_html("LP-")),
2595 }) as Arc<dyn PageFetcher>;
2596 let r = make_renderer_with_mocks(vec![lp]);
2597 let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2598 let res = REQUEST_PROXY
2601 .scope(Some(entry), async {
2602 r.fetch_with_js(
2603 "https://example.com",
2604 &HashMap::new(),
2605 None,
2606 None,
2607 crw_core::Deadline::from_request_ms(5000),
2608 )
2609 .await
2610 })
2611 .await;
2612 assert!(
2613 res.is_err(),
2614 "lightpanda-only + proxy active must fail closed, got {res:?}"
2615 );
2616 }
2617
2618 #[tokio::test]
2619 async fn proxy_active_prefers_chrome_over_lightpanda() {
2620 let lp = Arc::new(MockFetcher {
2623 name: "lightpanda",
2624 behavior: MockBehavior::Ok(rich_html("LP-")),
2625 }) as Arc<dyn PageFetcher>;
2626 let chrome = Arc::new(MockFetcher {
2627 name: "chrome",
2628 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2629 }) as Arc<dyn PageFetcher>;
2630 let r = make_renderer_with_mocks(vec![lp, chrome]);
2631 let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2632 let res = REQUEST_PROXY
2633 .scope(Some(entry), async {
2634 r.fetch_with_js(
2635 "https://example.com",
2636 &HashMap::new(),
2637 None,
2638 None,
2639 crw_core::Deadline::from_request_ms(5000),
2640 )
2641 .await
2642 })
2643 .await
2644 .unwrap();
2645 assert_eq!(res.rendered_with.as_deref(), Some("chrome"));
2646 }
2647
2648 #[tokio::test]
2649 async fn fetch_with_pinned_renderer_filters_pool() {
2650 let lp = Arc::new(MockFetcher {
2651 name: "lightpanda",
2652 behavior: MockBehavior::Ok(rich_html("LP-")),
2653 }) as Arc<dyn PageFetcher>;
2654 let chrome = Arc::new(MockFetcher {
2655 name: "chrome",
2656 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2657 }) as Arc<dyn PageFetcher>;
2658 let r = make_renderer_with_mocks(vec![lp, chrome]);
2659
2660 let result = r
2661 .fetch(
2662 "https://example.com",
2663 &HashMap::new(),
2664 Some(true),
2665 None,
2666 Some("chrome"),
2667 tdl(),
2668 )
2669 .await
2670 .unwrap();
2671 assert!(result.html.contains("CHROME-"), "expected chrome output");
2672 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2673 }
2674
2675 #[tokio::test]
2676 async fn fetch_with_pinned_renderer_unknown_returns_error() {
2677 let chrome = Arc::new(MockFetcher {
2678 name: "chrome",
2679 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2680 }) as Arc<dyn PageFetcher>;
2681 let r = make_renderer_with_mocks(vec![chrome]);
2682
2683 let err = r
2684 .fetch(
2685 "https://example.com",
2686 &HashMap::new(),
2687 Some(true),
2688 None,
2689 Some("lightpanda"),
2690 tdl(),
2691 )
2692 .await
2693 .unwrap_err();
2694 let msg = err.to_string();
2695 assert!(
2696 msg.contains("lightpanda") && msg.contains("chrome"),
2697 "expected error to name pinned + available: {msg}"
2698 );
2699 }
2700
2701 #[tokio::test]
2702 async fn fetch_with_renderer_auto_uses_full_chain() {
2703 let lp = Arc::new(MockFetcher {
2704 name: "lightpanda",
2705 behavior: MockBehavior::Ok(rich_html("LP-")),
2706 }) as Arc<dyn PageFetcher>;
2707 let chrome = Arc::new(MockFetcher {
2708 name: "chrome",
2709 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2710 }) as Arc<dyn PageFetcher>;
2711 let r = make_renderer_with_mocks(vec![lp, chrome]);
2712
2713 let result = r
2714 .fetch(
2715 "https://example.com",
2716 &HashMap::new(),
2717 Some(true),
2718 None,
2719 Some("auto"),
2720 tdl(),
2721 )
2722 .await
2723 .unwrap();
2724 assert!(result.html.contains("LP-"), "expected lightpanda first");
2726 }
2727
2728 #[tokio::test]
2729 async fn failover_skips_renderer_that_returns_failed_render() {
2730 let bad_lp_html = format!(
2733 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2734 "x".repeat(200)
2735 );
2736 let lp = Arc::new(MockFetcher {
2737 name: "lightpanda",
2738 behavior: MockBehavior::Ok(bad_lp_html),
2739 }) as Arc<dyn PageFetcher>;
2740 let chrome = Arc::new(MockFetcher {
2741 name: "chrome",
2742 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2743 }) as Arc<dyn PageFetcher>;
2744 let r = make_renderer_with_mocks(vec![lp, chrome]);
2745
2746 let result = r
2747 .fetch(
2748 "https://example.com",
2749 &HashMap::new(),
2750 Some(true),
2751 None,
2752 None,
2753 tdl(),
2754 )
2755 .await
2756 .unwrap();
2757 assert!(result.html.contains("CHROME-OK"));
2758 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2759 }
2760
2761 #[tokio::test]
2762 async fn failover_surfaces_warning_when_only_failed_render_available() {
2763 let bad_lp_html = format!(
2767 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2768 "x".repeat(200)
2769 );
2770 let lp = Arc::new(MockFetcher {
2771 name: "lightpanda",
2772 behavior: MockBehavior::Ok(bad_lp_html),
2773 }) as Arc<dyn PageFetcher>;
2774 let r = make_renderer_with_mocks(vec![lp]);
2775
2776 let result = r
2777 .fetch(
2778 "https://example.com",
2779 &HashMap::new(),
2780 Some(true),
2781 None,
2782 None,
2783 tdl(),
2784 )
2785 .await
2786 .unwrap();
2787 let warning = result.warning.expect("expected warning to be set");
2788 assert!(
2789 warning.contains("lightpanda") && warning.contains("nextjs_client_error"),
2790 "warning should name renderer + reason: {warning}"
2791 );
2792 }
2793
2794 #[tokio::test]
2795 async fn failover_concats_warnings_across_two_failed_renderers() {
2796 let bad_lp_html = format!(
2800 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2801 "x".repeat(200)
2802 );
2803 let bad_chrome_html = format!(
2804 "<html><body><div id=\"__next_error__\">{}</div></body></html>",
2805 "y".repeat(200)
2806 );
2807 let lp = Arc::new(MockFetcher {
2808 name: "lightpanda",
2809 behavior: MockBehavior::Ok(bad_lp_html),
2810 }) as Arc<dyn PageFetcher>;
2811 let chrome = Arc::new(MockFetcher {
2812 name: "chrome",
2813 behavior: MockBehavior::Ok(bad_chrome_html),
2814 }) as Arc<dyn PageFetcher>;
2815 let r = make_renderer_with_mocks(vec![lp, chrome]);
2816
2817 let result = r
2818 .fetch(
2819 "https://example.com",
2820 &HashMap::new(),
2821 Some(true),
2822 None,
2823 None,
2824 tdl(),
2825 )
2826 .await
2827 .unwrap();
2828 let warning = result.warning.expect("expected warning to be set");
2829 assert!(
2830 warning.contains("lightpanda") && warning.contains("chrome"),
2831 "warning should mention both renderers: {warning}"
2832 );
2833 }
2834
2835 #[tokio::test]
2836 async fn fetch_pinned_renderer_failure_propagates() {
2837 let chrome = Arc::new(MockFetcher {
2838 name: "chrome",
2839 behavior: MockBehavior::Err("boom".into()),
2840 }) as Arc<dyn PageFetcher>;
2841 let r = make_renderer_with_mocks(vec![chrome]);
2842
2843 let err = r
2844 .fetch(
2845 "https://example.com",
2846 &HashMap::new(),
2847 Some(true),
2848 None,
2849 Some("chrome"),
2850 tdl(),
2851 )
2852 .await
2853 .unwrap_err();
2854 assert!(err.to_string().contains("boom"));
2855 }
2856
2857 #[tokio::test]
2858 async fn auto_promoted_host_tries_chrome_first() {
2859 let lp = Arc::new(MockFetcher {
2863 name: "lightpanda",
2864 behavior: MockBehavior::Ok(rich_html("LP-")),
2865 }) as Arc<dyn PageFetcher>;
2866 let chrome = Arc::new(MockFetcher {
2867 name: "chrome",
2868 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2869 }) as Arc<dyn PageFetcher>;
2870 let r = make_renderer_with_mocks(vec![lp, chrome]);
2871
2872 for _ in 0..3 {
2874 r.preferences
2875 .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
2876 .await;
2877 }
2878
2879 let result = r
2880 .fetch(
2881 "https://example.com",
2882 &HashMap::new(),
2883 Some(true),
2884 None,
2885 None,
2886 tdl(),
2887 )
2888 .await
2889 .unwrap();
2890 assert!(
2891 result.html.contains("CHROME-"),
2892 "promoted host should hit chrome first, got: {}",
2893 &result.html[..80.min(result.html.len())]
2894 );
2895 assert_eq!(result.credit_cost, 1, "every renderer costs 1 credit");
2896 assert!(matches!(
2897 result.render_decision,
2898 Some(RenderDecision::AutoPromoted {
2899 chosen: RendererKind::Chrome,
2900 ..
2901 })
2902 ));
2903 }
2904
2905 #[tokio::test]
2906 async fn breaker_skipped_renderer_falls_through_to_next() {
2907 let lp = Arc::new(MockFetcher {
2910 name: "lightpanda",
2911 behavior: MockBehavior::Err("would fire if reached".into()),
2912 }) as Arc<dyn PageFetcher>;
2913 let chrome = Arc::new(MockFetcher {
2914 name: "chrome",
2915 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2916 }) as Arc<dyn PageFetcher>;
2917 let mut r = make_renderer_with_mocks(vec![lp, chrome]);
2918
2919 let breaker_cfg = BreakerConfig {
2925 base_cooldown: Duration::from_secs(300),
2926 max_cooldown: Duration::from_secs(300),
2927 ..BreakerConfig::default()
2928 };
2929 r.breakers = Arc::new(BreakerRegistry::new(breaker_cfg));
2930 for _ in 0..80 {
2931 r.breakers
2932 .record_result("example.com", RendererKind::Lightpanda, false)
2933 .await;
2934 }
2935
2936 let result = r
2937 .fetch(
2938 "https://example.com",
2939 &HashMap::new(),
2940 Some(true),
2941 None,
2942 None,
2943 tdl(),
2944 )
2945 .await
2946 .unwrap();
2947 assert!(result.html.contains("CHROME-OK"));
2948 assert!(matches!(
2949 result.render_decision,
2950 Some(RenderDecision::BreakerSkipped {
2951 skipped: RendererKind::Lightpanda,
2952 chosen: RendererKind::Chrome
2953 })
2954 ));
2955 }
2956
2957 #[tokio::test]
2958 async fn user_pinned_failed_render_emits_warning() {
2959 let bad_html = format!(
2964 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2965 "x".repeat(200)
2966 );
2967 let lp = Arc::new(MockFetcher {
2968 name: "lightpanda",
2969 behavior: MockBehavior::Ok(bad_html),
2970 }) as Arc<dyn PageFetcher>;
2971 let chrome = Arc::new(MockFetcher {
2972 name: "chrome",
2973 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2974 }) as Arc<dyn PageFetcher>;
2975 let r = make_renderer_with_mocks(vec![lp, chrome]);
2976
2977 let result = r
2978 .fetch(
2979 "https://example.com",
2980 &HashMap::new(),
2981 Some(true),
2982 None,
2983 Some("lightpanda"),
2984 tdl(),
2985 )
2986 .await
2987 .unwrap();
2988 let pin_hint = result
2989 .warnings
2990 .iter()
2991 .find(|w| w.starts_with("Pinned renderer 'lightpanda'"));
2992 assert!(
2993 pin_hint.is_some(),
2994 "expected pin-failure hint in warnings, got: {:?}",
2995 result.warnings
2996 );
2997 let hint = pin_hint.unwrap();
2998 assert!(
2999 hint.contains("nextJsClientError"),
3000 "hint should name camelCase reason: {hint}"
3001 );
3002 assert!(
3003 hint.contains("renderer=\"chrome\""),
3004 "hint should suggest a fix: {hint}"
3005 );
3006 assert!(matches!(
3008 result.render_decision,
3009 Some(RenderDecision::Failover { ref chain, .. }) if chain.len() == 1
3010 ));
3011 }
3012
3013 #[tokio::test]
3014 async fn user_pinned_decision_records_credit_and_kind() {
3015 let chrome = Arc::new(MockFetcher {
3016 name: "chrome",
3017 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3018 }) as Arc<dyn PageFetcher>;
3019 let r = make_renderer_with_mocks(vec![chrome]);
3020 let result = r
3021 .fetch(
3022 "https://example.com",
3023 &HashMap::new(),
3024 Some(true),
3025 None,
3026 Some("chrome"),
3027 tdl(),
3028 )
3029 .await
3030 .unwrap();
3031 assert_eq!(result.credit_cost, 1);
3032 assert!(matches!(
3033 result.render_decision,
3034 Some(RenderDecision::UserPinned {
3035 renderer: RendererKind::Chrome
3036 })
3037 ));
3038 }
3039
3040 #[tokio::test]
3041 async fn js_tier_escalates_on_403_status() {
3042 let lp = Arc::new(MockFetcher {
3045 name: "lightpanda",
3046 behavior: MockBehavior::OkStatus(403, rich_html("BLOCKED-")),
3047 }) as Arc<dyn PageFetcher>;
3048 let chrome = Arc::new(MockFetcher {
3049 name: "chrome",
3050 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3051 }) as Arc<dyn PageFetcher>;
3052 let r = make_renderer_with_mocks(vec![lp, chrome]);
3053
3054 let result = r
3055 .fetch(
3056 "https://example.com",
3057 &HashMap::new(),
3058 Some(true),
3059 None,
3060 Some("auto"),
3061 tdl(),
3062 )
3063 .await
3064 .unwrap();
3065 assert!(
3066 result.html.contains("CHROME-"),
3067 "expected chrome output after lightpanda 403"
3068 );
3069 assert_eq!(result.status_code, 200);
3070 }
3071
3072 #[tokio::test]
3073 async fn js_tier_escalates_on_vendor_block_with_200() {
3074 let cf_html = format!(
3077 "<html><head><script src=\"/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1\"></script></head><body>{}</body></html>",
3078 "x".repeat(200)
3079 );
3080 let lp = Arc::new(MockFetcher {
3081 name: "lightpanda",
3082 behavior: MockBehavior::Ok(cf_html),
3083 }) as Arc<dyn PageFetcher>;
3084 let chrome = Arc::new(MockFetcher {
3085 name: "chrome",
3086 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3087 }) as Arc<dyn PageFetcher>;
3088 let r = make_renderer_with_mocks(vec![lp, chrome]);
3089
3090 let result = r
3091 .fetch(
3092 "https://example.com",
3093 &HashMap::new(),
3094 Some(true),
3095 None,
3096 Some("auto"),
3097 tdl(),
3098 )
3099 .await
3100 .unwrap();
3101 assert!(
3102 result.html.contains("CHROME-"),
3103 "expected chrome output after lightpanda vendor block"
3104 );
3105 }
3106
3107 #[tokio::test]
3108 async fn js_tier_accepts_200_clean_response() {
3109 let lp = Arc::new(MockFetcher {
3112 name: "lightpanda",
3113 behavior: MockBehavior::Ok(rich_html("LP-CLEAN-")),
3114 }) as Arc<dyn PageFetcher>;
3115 let chrome = Arc::new(MockFetcher {
3116 name: "chrome",
3117 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3118 }) as Arc<dyn PageFetcher>;
3119 let r = make_renderer_with_mocks(vec![lp, chrome]);
3120
3121 let result = r
3122 .fetch(
3123 "https://example.com",
3124 &HashMap::new(),
3125 Some(true),
3126 None,
3127 Some("auto"),
3128 tdl(),
3129 )
3130 .await
3131 .unwrap();
3132 assert!(result.html.contains("LP-CLEAN-"));
3133 assert_eq!(result.status_code, 200);
3134 }
3135
3136 fn network_security_block_html() -> String {
3140 format!(
3141 "<html><body><article>You've been blocked by network security.{}</article></body></html>",
3142 "x".repeat(200)
3143 )
3144 }
3145
3146 #[tokio::test]
3147 async fn js_tier_escalates_to_chrome_proxy_on_antibot_block() {
3148 let lp = Arc::new(MockFetcher {
3151 name: "lightpanda",
3152 behavior: MockBehavior::Ok(network_security_block_html()),
3153 }) as Arc<dyn PageFetcher>;
3154 let chrome = Arc::new(MockFetcher {
3155 name: "chrome",
3156 behavior: MockBehavior::Ok(network_security_block_html()),
3157 }) as Arc<dyn PageFetcher>;
3158 let chrome_proxy = Arc::new(MockFetcher {
3159 name: "chrome_proxy",
3160 behavior: MockBehavior::Ok(rich_html("PROXY-")),
3161 }) as Arc<dyn PageFetcher>;
3162 let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3163
3164 let result = r
3165 .fetch(
3166 "https://example.com",
3167 &HashMap::new(),
3168 Some(true),
3169 None,
3170 Some("auto"),
3171 tdl(),
3172 )
3173 .await
3174 .unwrap();
3175 assert!(
3176 result.html.contains("PROXY-"),
3177 "expected chrome_proxy output after antibot block"
3178 );
3179 assert_eq!(
3180 result.render_decision,
3181 Some(RenderDecision::Failover {
3182 chain: vec![
3183 RendererKind::Lightpanda,
3184 RendererKind::Chrome,
3185 RendererKind::ChromeProxy,
3186 ],
3187 reason: FailoverErrorKind::AntibotBlock,
3188 })
3189 );
3190 }
3191
3192 #[tokio::test]
3193 async fn antibot_block_returns_as_success_when_escalation_disabled() {
3194 let lp = Arc::new(MockFetcher {
3198 name: "lightpanda",
3199 behavior: MockBehavior::Ok(network_security_block_html()),
3200 }) as Arc<dyn PageFetcher>;
3201 let chrome = Arc::new(MockFetcher {
3202 name: "chrome",
3203 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3204 }) as Arc<dyn PageFetcher>;
3205 let mut r = make_renderer_with_mocks(vec![lp, chrome]);
3206 r.antibot.escalate_in_failover = false;
3207
3208 let result = r
3209 .fetch(
3210 "https://example.com",
3211 &HashMap::new(),
3212 Some(true),
3213 None,
3214 Some("auto"),
3215 tdl(),
3216 )
3217 .await
3218 .unwrap();
3219 assert!(
3220 result.html.contains("network security"),
3221 "block page should be returned as-is when escalation is disabled"
3222 );
3223 assert_eq!(result.rendered_with.as_deref(), Some("lightpanda"));
3224 }
3225
3226 #[tokio::test]
3227 async fn promoted_host_escalates_chrome_to_chrome_proxy_not_lightpanda() {
3228 let lp = Arc::new(MockFetcher {
3232 name: "lightpanda",
3233 behavior: MockBehavior::Ok(rich_html("LP-")),
3234 }) as Arc<dyn PageFetcher>;
3235 let chrome = Arc::new(MockFetcher {
3236 name: "chrome",
3237 behavior: MockBehavior::Ok(network_security_block_html()),
3238 }) as Arc<dyn PageFetcher>;
3239 let chrome_proxy = Arc::new(MockFetcher {
3240 name: "chrome_proxy",
3241 behavior: MockBehavior::Ok(rich_html("PROXY-")),
3242 }) as Arc<dyn PageFetcher>;
3243 let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3244
3245 for _ in 0..3 {
3247 r.preferences
3248 .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
3249 .await;
3250 }
3251
3252 let result = r
3253 .fetch(
3254 "https://example.com",
3255 &HashMap::new(),
3256 Some(true),
3257 None,
3258 None,
3259 tdl(),
3260 )
3261 .await
3262 .unwrap();
3263 assert!(
3264 result.html.contains("PROXY-"),
3265 "expected chrome_proxy output"
3266 );
3267 assert_eq!(
3268 result.render_decision,
3269 Some(RenderDecision::Failover {
3270 chain: vec![RendererKind::Chrome, RendererKind::ChromeProxy],
3271 reason: FailoverErrorKind::AntibotBlock,
3272 }),
3273 "chrome must escalate straight to chrome_proxy, skipping lightpanda"
3274 );
3275 }
3276}