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 screenshot_requested() -> bool {
112 REQUEST_SCREENSHOT
113 .try_with(|s| s.is_some())
114 .unwrap_or(false)
115}
116
117pub fn current_screenshot_req() -> Option<ScreenshotReq> {
119 REQUEST_SCREENSHOT.try_with(|s| *s).ok().flatten()
120}
121
122fn renderer_kind_for(name: &str) -> Option<RendererKind> {
126 match name {
127 "http" | "http_only_fallback" => Some(RendererKind::Http),
128 "lightpanda" => Some(RendererKind::Lightpanda),
129 "chrome" => Some(RendererKind::Chrome),
130 "chrome_proxy" => Some(RendererKind::ChromeProxy),
131 "camoufox" => Some(RendererKind::Camoufox),
132 _ => None,
133 }
134}
135
136fn classify_renderer_error(err: &CrwError) -> FailoverErrorKind {
146 match err {
147 CrwError::Timeout(_) => FailoverErrorKind::LightpandaTimeout,
148 CrwError::TargetUnreachable(_) => FailoverErrorKind::NetworkError,
149 CrwError::HttpError(_) => FailoverErrorKind::NetworkError,
150 CrwError::RendererError(_) => FailoverErrorKind::LightpandaCrash,
153 _ => FailoverErrorKind::Other,
154 }
155}
156
157fn tier_timeouts_from(
160 config: &RendererConfig,
161) -> std::collections::HashMap<RendererKind, std::time::Duration> {
162 let mut m = std::collections::HashMap::new();
163 m.insert(
164 RendererKind::Http,
165 std::time::Duration::from_millis(config.http_timeout()),
166 );
167 m.insert(
168 RendererKind::Lightpanda,
169 std::time::Duration::from_millis(config.lightpanda_timeout()),
170 );
171 m.insert(
172 RendererKind::Chrome,
173 std::time::Duration::from_millis(config.chrome_timeout()),
174 );
175 m.insert(
176 RendererKind::ChromeProxy,
177 std::time::Duration::from_millis(config.chrome_proxy_timeout()),
178 );
179 m.insert(
184 RendererKind::Camoufox,
185 std::time::Duration::from_millis(config.camoufox_timeout()),
186 );
187 m
188}
189
190fn credit_for(_kind: RendererKind) -> u32 {
196 1
197}
198
199fn stamp_http_decision(result: &mut FetchResult, requested_renderer: Option<&str>) {
203 if result.render_decision.is_some() {
204 return;
205 }
206 let kind = RendererKind::Http;
207 result.credit_cost = credit_for(kind);
208 result.render_decision = Some(match requested_renderer {
209 Some("http") => RenderDecision::UserPinned { renderer: kind },
210 _ => RenderDecision::AutoDefault { chosen: kind },
211 });
212 metrics()
214 .render_route_decision_total
215 .with_label_values(&[kind.as_str(), "success"])
216 .inc();
217}
218
219fn host_of(url: &str) -> String {
221 url::Url::parse(url)
222 .ok()
223 .and_then(|u| u.host_str().map(|h| h.to_string()))
224 .unwrap_or_default()
225}
226
227fn pick_ua<'a>(default_ua: &'a str, stealth: &'a StealthConfig) -> String {
229 if stealth.enabled {
230 let pool: &[&str] = if stealth.user_agents.is_empty() {
231 BUILTIN_UA_POOL
232 } else {
233 return stealth.user_agents[rand::random_range(0..stealth.user_agents.len())].clone();
235 };
236 pool[rand::random_range(0..pool.len())].to_string()
237 } else {
238 default_ua.to_string()
239 }
240}
241
242#[allow(dead_code)] struct JsAttemptClass {
247 text_len: usize,
248 is_placeholder: bool,
249 failed_render: Option<detector::FailedRenderReason>,
250 is_bot_wall: bool,
251 vendor_block: Option<&'static str>,
252 is_status_blocked: bool,
253 antibot: crw_extract::antibot::AntibotResult,
254 antibot_blocked: bool,
255 hard_block: bool,
257 acceptable: bool,
259}
260
261enum HedgeOutcome {
263 Accepted(FetchResult),
265 Thin(FetchResult, bool),
269}
270
271pub struct FallbackRenderer {
273 http: Arc<dyn PageFetcher>,
274 js_renderers: Vec<Arc<dyn PageFetcher>>,
275 render_js_default: Option<bool>,
277 latency_breakdown: bool,
279 auto_egress_escalation: bool,
282 chrome_hedge: bool,
284 hedge_sem: Arc<tokio::sync::Semaphore>,
289 preferences: Arc<HostPreferences>,
291 breakers: Arc<BreakerRegistry>,
293 tier_timeouts: std::collections::HashMap<RendererKind, std::time::Duration>,
297 requests_per_second: f64,
301 per_host_max_concurrent: u32,
303 antibot: crw_core::config::AntibotConfig,
307 proxy_rotator: Option<Arc<crw_core::ProxyRotator>>,
311 http_ua: String,
314 http_inject_stealth: bool,
315 http_timeout_ms: u64,
316 proxy_client_cache: std::sync::Mutex<std::collections::HashMap<String, Arc<dyn PageFetcher>>>,
321 #[cfg(feature = "cdp")]
324 chrome_pool: Option<Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>>,
325 #[cfg(feature = "camoufox")]
330 camoufox_in_auto: bool,
331}
332
333impl std::fmt::Debug for FallbackRenderer {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 f.debug_struct("FallbackRenderer")
336 .field("http", &self.http.name())
337 .field(
338 "js_renderers",
339 &self
340 .js_renderers
341 .iter()
342 .map(|r| r.name())
343 .collect::<Vec<_>>(),
344 )
345 .field("render_js_default", &self.render_js_default)
346 .finish()
347 }
348}
349
350impl FallbackRenderer {
351 pub fn new(
352 config: &RendererConfig,
353 user_agent: &str,
354 proxy: Option<&str>,
355 stealth: &StealthConfig,
356 ) -> CrwResult<Self> {
357 let effective_ua = pick_ua(user_agent, stealth);
358 let inject_headers = stealth.enabled && stealth.inject_headers;
359 let http_timeout_ms = config.http_timeout();
360 if let Some(p) = proxy {
363 crw_core::ProxyEntry::parse(p).map_err(CrwError::ConfigError)?;
364 }
365 let http = Arc::new(http_only::HttpFetcher::with_timeout(
366 &effective_ua,
367 proxy,
368 inject_headers,
369 std::time::Duration::from_millis(http_timeout_ms),
370 )) as Arc<dyn PageFetcher>;
371
372 #[cfg(not(feature = "cdp"))]
376 if matches!(
377 config.mode,
378 RendererMode::Lightpanda | RendererMode::Chrome | RendererMode::Playwright
379 ) {
380 return Err(CrwError::ConfigError(format!(
381 "renderer.mode = {:?} requires the 'cdp' feature, but this build was \
382 compiled without it. Rebuild with --features cdp or set mode = \"auto\"/\"none\".",
383 config.mode
384 )));
385 }
386
387 #[cfg(not(feature = "camoufox"))]
391 if matches!(config.mode, RendererMode::Camoufox) {
392 return Err(CrwError::ConfigError(
393 "renderer.mode = \"camoufox\" requires the 'camoufox' feature, but this build \
394 was compiled without it. Rebuild with --features camoufox or set mode = \
395 \"auto\"/\"none\"."
396 .into(),
397 ));
398 }
399
400 #[allow(unused_mut)]
401 let mut js_renderers: Vec<Arc<dyn PageFetcher>> = Vec::new();
402
403 if matches!(config.mode, RendererMode::None) {
404 if config.render_js_default == Some(true) {
405 tracing::warn!(
406 "render_js_default=true has no effect with mode=none; \
407 requests will fall back to HTTP via http_only_fallback"
408 );
409 }
410 return Ok(Self {
411 http,
412 js_renderers,
413 render_js_default: config.render_js_default,
414 latency_breakdown: config.latency_breakdown,
415 auto_egress_escalation: config.auto_egress_escalation,
416 chrome_hedge: config.chrome_hedge,
417 hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
418 preferences: Arc::new(HostPreferences::with_defaults()),
419 breakers: Arc::new(BreakerRegistry::with_defaults()),
420 tier_timeouts: tier_timeouts_from(config),
421 requests_per_second: 0.0,
422 per_host_max_concurrent: 1,
423 antibot: config.antibot.clone(),
424 proxy_rotator: None,
425 http_ua: effective_ua.clone(),
426 http_inject_stealth: inject_headers,
427 http_timeout_ms,
428 proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
429 #[cfg(feature = "cdp")]
430 chrome_pool: None,
431 #[cfg(feature = "camoufox")]
434 camoufox_in_auto: false,
435 });
436 }
437
438 #[cfg(feature = "cdp")]
439 let mut chrome_pool: Option<
440 Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>,
441 > = None;
442
443 #[cfg(feature = "cdp")]
444 {
445 let want = |m: RendererMode| -> bool {
446 matches!(config.mode, RendererMode::Auto) || config.mode == m
447 };
448
449 if want(RendererMode::Lightpanda) {
450 if let Some(lp) = &config.lightpanda {
451 js_renderers.push(Arc::new(
452 cdp::CdpRenderer::new(
453 "lightpanda",
454 &lp.ws_url,
455 config.lightpanda_timeout(),
456 config.pool_size,
457 )
458 .with_user_agent(&effective_ua),
459 ));
460 } else if matches!(config.mode, RendererMode::Lightpanda) {
461 return Err(CrwError::ConfigError(
462 "renderer.mode = \"lightpanda\" but [renderer.lightpanda] ws_url is not \
463 configured"
464 .into(),
465 ));
466 }
467 }
468 if want(RendererMode::Playwright) {
469 if let Some(pw) = &config.playwright {
470 js_renderers.push(Arc::new(
473 cdp::CdpRenderer::new(
474 "playwright",
475 &pw.ws_url,
476 config.chrome_timeout(),
477 config.pool_size,
478 )
479 .with_user_agent(&effective_ua),
480 ));
481 } else if matches!(config.mode, RendererMode::Playwright) {
482 return Err(CrwError::ConfigError(
483 "renderer.mode = \"playwright\" but [renderer.playwright] ws_url is not \
484 configured"
485 .into(),
486 ));
487 }
488 }
489 if want(RendererMode::Chrome) {
490 if let Some(ch) = &config.chrome {
491 let blocklist = blocklist::Blocklist::defaults()
492 .with_stylesheets(config.chrome_intercept_stylesheets);
493 let mut renderer = cdp::CdpRenderer::new(
494 "chrome",
495 &ch.ws_url,
496 config.chrome_timeout(),
497 config.pool_size,
498 )
499 .with_user_agent(&effective_ua)
500 .with_nav_budget(config.chrome_nav_budget_ms)
501 .with_challenge_retries(
502 config
503 .chrome_challenge_max_retries
504 .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
505 )
506 .with_spa_selector_max(
507 config
508 .chrome_spa_selector_max_ms
509 .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
510 )
511 .with_fast_ready(config.chrome_fast_ready)
512 .with_interception(
513 config.chrome_intercept_resources,
514 blocklist,
515 config.chrome_host_intercept_disable.clone(),
516 );
517
518 if config.chrome_context_pool_enabled {
522 match config.chrome_backend {
523 crw_core::config::ChromeBackend::Vanilla => {
524 let pcfg = &config.chrome_pool;
525 let size = pcfg.size.unwrap_or_else(|| {
526 let n = std::thread::available_parallelism()
527 .map(|p| p.get())
528 .unwrap_or(2);
529 std::cmp::max(2, n / 2)
530 });
531 renderer = renderer.with_pool(browser_pool::PoolCfg {
532 size,
533 recycle_after_navs: pcfg.recycle_after_navs,
534 idle_timeout: std::time::Duration::from_secs(
535 pcfg.idle_timeout_secs,
536 ),
537 health_check_after: std::time::Duration::from_secs(
538 pcfg.health_check_secs,
539 ),
540 shutdown_drain: std::time::Duration::from_secs(
541 pcfg.shutdown_drain_secs,
542 ),
543 close_target_timeout: std::time::Duration::from_secs(2),
544 dispose_ctx_timeout: std::time::Duration::from_secs(1),
545 create_ctx_timeout: std::time::Duration::from_secs(1),
546 });
547 tracing::info!(
548 pool_size = size,
549 "chrome browser-context pool enabled"
550 );
551 }
552 crw_core::config::ChromeBackend::Browserless => {
553 tracing::warn!(
554 "chrome_context_pool_enabled = true but \
555 chrome_backend = browserless — pool unsupported on \
556 this backend in v1, falling back to legacy path"
557 );
558 }
559 }
560 }
561 chrome_pool = renderer.pool();
562 js_renderers.push(Arc::new(renderer));
563 } else if matches!(config.mode, RendererMode::Chrome) {
564 return Err(CrwError::ConfigError(
565 "renderer.mode = \"chrome\" but [renderer.chrome] ws_url is not configured"
566 .into(),
567 ));
568 }
569 if let Some(cp) = config
577 .chrome_proxy
578 .as_ref()
579 .filter(|c| !c.ws_url.trim().is_empty())
580 {
581 let blocklist = blocklist::Blocklist::defaults()
582 .with_stylesheets(config.chrome_intercept_stylesheets);
583 let mut renderer = cdp::CdpRenderer::new(
584 "chrome_proxy",
585 &cp.ws_url,
586 config.chrome_proxy_timeout(),
587 config.pool_size,
588 )
589 .with_user_agent(&effective_ua)
590 .with_nav_budget(config.chrome_nav_budget_ms)
591 .with_challenge_retries(
592 config
593 .chrome_challenge_max_retries
594 .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
595 )
596 .with_spa_selector_max(
597 config
598 .chrome_spa_selector_max_ms
599 .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
600 )
601 .with_fast_ready(config.chrome_fast_ready)
602 .with_interception(
603 config.chrome_intercept_resources,
604 blocklist,
605 config.chrome_host_intercept_disable.clone(),
606 );
607 if let (Some(u), Some(p)) = (&config.proxy_base_user, &config.proxy_base_pass) {
612 renderer = renderer.with_proxy_auth_base(
613 u.clone(),
614 p.clone(),
615 config.proxy_default_country.clone(),
616 );
617 }
618 tracing::info!(
619 ws_url = %cp.ws_url,
620 proxy_auth = config.proxy_base_user.is_some(),
621 default_country = ?config.proxy_default_country,
622 "chrome_proxy tier enabled"
623 );
624 js_renderers.push(Arc::new(renderer));
625 }
626 }
627 }
628
629 #[cfg(feature = "camoufox")]
637 {
638 if let Some(cf) = config
639 .camoufox
640 .as_ref()
641 .filter(|c| !c.base_url.trim().is_empty())
642 {
643 js_renderers.push(Arc::new(camoufox::CamoufoxRenderer::new(
644 "camoufox",
645 &cf.base_url,
646 &cf.api_key,
647 config.camoufox_timeout(),
648 )) as Arc<dyn PageFetcher>);
649 tracing::info!(
650 base_url = %cf.base_url,
651 include_in_auto = cf.include_in_auto,
652 "camoufox tier enabled"
653 );
654 } else if matches!(config.mode, RendererMode::Camoufox) {
655 return Err(CrwError::ConfigError(
656 "renderer.mode = \"camoufox\" but [renderer.camoufox] base_url is not configured"
657 .into(),
658 ));
659 }
660 }
661
662 #[cfg(feature = "cdp")]
666 health_telemetry::spawn_once();
667
668 if config.render_js_default == Some(true) && js_renderers.is_empty() {
669 tracing::warn!(
670 "render_js_default=true but no JS renderer is available; \
671 requests will fall back to HTTP via http_only_fallback"
672 );
673 }
674
675 Ok(Self {
676 http,
677 js_renderers,
678 render_js_default: config.render_js_default,
679 latency_breakdown: config.latency_breakdown,
680 auto_egress_escalation: config.auto_egress_escalation,
681 chrome_hedge: config.chrome_hedge,
682 hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
683 preferences: Arc::new(HostPreferences::with_defaults()),
684 breakers: Arc::new(BreakerRegistry::with_defaults()),
685 tier_timeouts: tier_timeouts_from(config),
686 requests_per_second: 0.0,
687 per_host_max_concurrent: 1,
688 antibot: config.antibot.clone(),
689 proxy_rotator: None,
690 http_ua: effective_ua.clone(),
691 http_inject_stealth: inject_headers,
692 http_timeout_ms,
693 proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
694 #[cfg(feature = "cdp")]
695 chrome_pool,
696 #[cfg(feature = "camoufox")]
700 camoufox_in_auto: config.camoufox_in_ladder(),
701 })
702 }
703
704 pub fn with_proxy_rotator(
710 mut self,
711 rotator: Option<Arc<crw_core::ProxyRotator>>,
712 ) -> CrwResult<Self> {
713 self.proxy_rotator = rotator;
714 Ok(self)
715 }
716
717 fn http_fetcher_for_request(&self) -> CrwResult<Arc<dyn PageFetcher>> {
724 let Some(entry) = REQUEST_PROXY.try_with(|p| p.clone()).ok().flatten() else {
725 return Ok(self.http.clone());
726 };
727 if let Some(f) = self
729 .proxy_client_cache
730 .lock()
731 .unwrap_or_else(|e| e.into_inner())
732 .get(entry.raw())
733 .cloned()
734 {
735 return Ok(f);
736 }
737 let fetcher: Arc<dyn PageFetcher> = Arc::new(http_only::HttpFetcher::with_proxy(
738 &self.http_ua,
739 entry.raw(),
740 self.http_inject_stealth,
741 std::time::Duration::from_millis(self.http_timeout_ms),
742 )?);
743 let mut cache = self
744 .proxy_client_cache
745 .lock()
746 .unwrap_or_else(|e| e.into_inner());
747 if cache.len() >= 512 {
749 cache.clear();
750 }
751 cache.insert(entry.raw().to_string(), fetcher.clone());
752 Ok(fetcher)
753 }
754
755 pub fn pick_proxy(&self, host: Option<&str>) -> Option<Arc<crw_core::ProxyEntry>> {
760 self.proxy_rotator
761 .as_ref()
762 .map(|r| Arc::new(r.pick(host).clone()))
763 }
764
765 pub fn js_capable(&self) -> bool {
770 !self.js_renderers.is_empty() || self.auto_egress_escalation
771 }
772
773 pub fn pick_proxy_for_url(&self, url: &str) -> Option<Arc<crw_core::ProxyEntry>> {
777 self.proxy_rotator.as_ref()?;
778 let host = url::Url::parse(url)
779 .ok()
780 .and_then(|u| u.host_str().map(crate::preference::normalize_host));
781 self.pick_proxy(host.as_deref())
782 }
783
784 #[cfg(feature = "cdp")]
788 pub async fn shutdown_chrome_pool(&self, drain: std::time::Duration) {
789 if let Some(pool) = self.chrome_pool.clone() {
790 tracing::info!(
791 drain_secs = drain.as_secs(),
792 "draining chrome browser-context pool"
793 );
794 pool.shutdown(drain).await;
795 }
796 }
797
798 #[cfg(not(feature = "cdp"))]
800 pub async fn shutdown_chrome_pool(&self, _drain: std::time::Duration) {}
801
802 pub fn with_host_limits(
806 mut self,
807 requests_per_second: f64,
808 per_host_max_concurrent: u32,
809 ) -> Self {
810 self.requests_per_second = requests_per_second;
811 self.per_host_max_concurrent = per_host_max_concurrent;
812 self
813 }
814
815 pub fn preferences(&self) -> Arc<HostPreferences> {
817 Arc::clone(&self.preferences)
818 }
819
820 pub fn breakers(&self) -> Arc<BreakerRegistry> {
822 Arc::clone(&self.breakers)
823 }
824
825 pub fn js_renderer_names(&self) -> Vec<&str> {
828 self.js_renderers.iter().map(|r| r.name()).collect()
829 }
830
831 pub async fn fetch(
839 &self,
840 url: &str,
841 headers: &HashMap<String, String>,
842 render_js: Option<bool>,
843 wait_for_ms: Option<u64>,
844 requested_renderer: Option<&str>,
845 deadline: crw_core::Deadline,
846 ) -> CrwResult<FetchResult> {
847 if !self.latency_breakdown {
853 return self
854 .fetch_inner(
855 url,
856 headers,
857 render_js,
858 wait_for_ms,
859 requested_renderer,
860 deadline,
861 )
862 .await;
863 }
864 let t0 = std::time::Instant::now();
865 let out = self
866 .fetch_inner(
867 url,
868 headers,
869 render_js,
870 wait_for_ms,
871 requested_renderer,
872 deadline,
873 )
874 .await;
875 let total_ms = t0.elapsed().as_millis() as u64;
876 match &out {
877 Ok(r) => tracing::info!(
878 target: "latency_breakdown",
879 url,
880 total_ms,
881 rendered_with = r.rendered_with.as_deref().unwrap_or("unknown"),
882 content_len = r.html.len(),
883 "scrape latency breakdown"
884 ),
885 Err(e) => tracing::info!(
886 target: "latency_breakdown",
887 url,
888 total_ms,
889 error = %e,
890 "scrape latency breakdown (error)"
891 ),
892 }
893 out
894 }
895
896 async fn fetch_inner(
897 &self,
898 url: &str,
899 headers: &HashMap<String, String>,
900 render_js: Option<bool>,
901 wait_for_ms: Option<u64>,
902 requested_renderer: Option<&str>,
903 deadline: crw_core::Deadline,
904 ) -> CrwResult<FetchResult> {
905 let host_key = url::Url::parse(url)
909 .ok()
910 .and_then(|u| u.host_str().map(crate::preference::normalize_host));
911 let _host_permit = if let Some(key) = host_key.as_deref() {
912 let remaining = deadline.remaining();
913 if remaining.is_zero() {
914 return Err(CrwError::Timeout(
915 deadline.overrun().as_millis().max(1) as u64
916 ));
917 }
918 match tokio::time::timeout(
919 remaining,
920 crate::host_limiter::acquire(
921 key,
922 self.requests_per_second,
923 self.per_host_max_concurrent as usize,
924 ),
925 )
926 .await
927 {
928 Ok(Ok((permit, sleep))) => {
929 if !sleep.is_zero() {
930 let budget = deadline.remaining();
931 if sleep > budget {
932 return Err(CrwError::Timeout(sleep.as_millis().max(1) as u64));
933 }
934 tokio::time::sleep(sleep).await;
935 }
936 Some(permit)
937 }
938 Ok(Err(_)) => return Err(CrwError::RendererError("host limiter closed".into())),
939 Err(_) => {
940 return Err(CrwError::Timeout(
941 deadline.overrun().as_millis().max(1) as u64
942 ));
943 }
944 }
945 } else {
946 None
947 };
948
949 let mut effective = resolve_render_js(render_js, self.render_js_default);
950 if effective != Some(true) && screenshot_requested() {
956 effective = Some(true);
957 }
958 tracing::debug!(
959 url,
960 request_render_js = ?render_js,
961 default_render_js = ?self.render_js_default,
962 effective_render_js = ?effective,
963 requested_renderer,
964 "FallbackRenderer::fetch dispatching"
965 );
966 let is_hard_pinned = matches!(requested_renderer, Some(name) if name != "auto");
968 match effective {
969 Some(false) => {
970 let mut r = self
971 .http_fetcher_for_request()?
972 .fetch(url, headers, None, deadline)
973 .await?;
974 stamp_http_decision(&mut r, requested_renderer);
975 Ok(r)
976 }
977 Some(true) => {
978 let mut http_result = self
980 .http_fetcher_for_request()?
981 .fetch(url, headers, None, deadline)
982 .await?;
983 if http_result.content_type.as_deref() == Some("application/pdf") {
984 stamp_http_decision(&mut http_result, requested_renderer);
989 return Ok(http_result);
990 }
991
992 if self.js_renderers.is_empty() {
993 if screenshot_requested() {
997 return Err(CrwError::RendererError(
998 "a screenshot was requested but no JS renderer is available; \
999 configure a chrome/chrome_proxy tier"
1000 .into(),
1001 ));
1002 }
1003 tracing::warn!(
1004 url,
1005 "JS rendering requested but no renderer available — falling back to HTTP"
1006 );
1007 let mut result = http_result;
1008 result.rendered_with = Some("http_only_fallback".to_string());
1009 result.warning = Some("JS rendering was requested but no renderer is available. Content was fetched via HTTP only.".to_string());
1010 result.warnings.push(
1011 "JS rendering requested but no renderer available; HTTP fallback used"
1012 .into(),
1013 );
1014 stamp_http_decision(&mut result, requested_renderer);
1015 Ok(result)
1016 } else {
1017 self.fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1018 .await
1019 }
1020 }
1021 None => {
1022 let mut result = match self
1029 .http_fetcher_for_request()?
1030 .fetch(url, headers, None, deadline)
1031 .await
1032 {
1033 Ok(r) => r,
1034 Err(e) if !self.js_renderers.is_empty() => {
1035 tracing::info!(
1036 url,
1037 error = %e,
1038 "HTTP fetch failed, escalating to JS renderer"
1039 );
1040 return self
1041 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1042 .await
1043 .map_err(|js_err| {
1044 tracing::warn!("Both HTTP and JS failed: http={e}, js={js_err}");
1045 js_err
1046 });
1047 }
1048 Err(e) => return Err(e),
1049 };
1050
1051 if result.content_type.as_deref() == Some("application/pdf") {
1053 stamp_http_decision(&mut result, requested_renderer);
1054 return Ok(result);
1055 }
1056
1057 let needs_js = detector::needs_js_rendering(&result.html);
1058 let cf_header_signal = result.warning.as_deref() == Some("cloudflare_mitigated");
1059 let is_generic_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1060 let is_blocked = cf_header_signal
1061 || detector::looks_like_cloudflare_challenge(&result.html)
1062 || is_generic_bot_wall;
1063 let is_auth_blocked = matches!(
1076 result.status_code,
1077 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1078 );
1079 let is_2xx = (200..300).contains(&result.status_code);
1092 let is_thin_content = is_2xx
1093 && detector::looks_like_thin_html(&result.html)
1094 && detector::warrants_browser_retry(&result.html);
1095
1096 if !self.js_renderers.is_empty()
1097 && (needs_js || is_blocked || is_auth_blocked || is_thin_content)
1098 {
1099 if is_auth_blocked {
1100 tracing::info!(
1101 url,
1102 status_code = result.status_code,
1103 "HTTP {} received, escalating to JS renderer",
1104 result.status_code
1105 );
1106 } else if is_blocked {
1107 tracing::info!(
1108 url,
1109 "Anti-bot challenge detected in HTTP response, escalating to JS renderer"
1110 );
1111 if is_generic_bot_wall {
1112 tracing::info!(
1113 url,
1114 "Generic anti-bot interstitial detected, escalating to JS renderer"
1115 );
1116 }
1117 } else if needs_js {
1118 tracing::info!(url, "SPA shell detected, retrying with JS renderer");
1119 } else {
1120 tracing::info!(
1121 url,
1122 html_len = result.html.len(),
1123 "HTTP 2xx but body is thin, escalating to JS renderer"
1124 );
1125 }
1126 match self
1127 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1128 .await
1129 {
1130 Ok(js_result) => Ok(js_result),
1131 Err(e) if is_hard_pinned => {
1132 Err(e)
1135 }
1136 Err(e) => {
1137 if is_auth_blocked {
1145 tracing::error!(
1146 url,
1147 status_code = result.status_code,
1148 "JS escalation failed for soft-block status; surfacing HTTP shell with warning: {e}"
1149 );
1150 let warning = format!("js_escalation_failed: {e}");
1151 result.warning = Some(match result.warning.take() {
1152 Some(prev) => format!("{warning}; {prev}"),
1153 None => warning,
1154 });
1155 } else {
1156 tracing::warn!(
1157 "JS rendering failed, falling back to HTTP result: {e}"
1158 );
1159 }
1160 stamp_http_decision(&mut result, requested_renderer);
1161 Ok(result)
1162 }
1163 }
1164 } else {
1165 stamp_http_decision(&mut result, requested_renderer);
1166 Ok(result)
1167 }
1168 }
1169 }
1170 }
1171
1172 const MIN_RENDERED_TEXT_LEN: usize = 50;
1176
1177 fn classify_js_attempt(&self, result: &FetchResult) -> JsAttemptClass {
1182 let text_len = html_body_text_len(&result.html);
1183 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1184 let failed_render = detector::looks_like_failed_render(&result.html);
1185 let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1186 let vendor_block = detector::looks_like_vendor_block(&result.html);
1187 let is_status_blocked = matches!(
1188 result.status_code,
1189 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1190 );
1191 let antibot = if self.antibot.enabled {
1192 crw_extract::antibot::classify(Some(result.status_code), &result.html)
1193 } else {
1194 crw_extract::antibot::AntibotResult::none()
1195 };
1196 let antibot_blocked = self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1197 let hard_block = matches!(result.status_code, 401 | 403 | 429 | 503)
1199 || (520..=530).contains(&result.status_code)
1200 || is_bot_wall
1201 || vendor_block.is_some()
1202 || antibot.signal.is_blocked();
1203 let acceptable = text_len >= Self::MIN_RENDERED_TEXT_LEN
1204 && !is_placeholder
1205 && failed_render.is_none()
1206 && !is_bot_wall
1207 && vendor_block.is_none()
1208 && !is_status_blocked
1209 && !antibot_blocked;
1210 JsAttemptClass {
1211 text_len,
1212 is_placeholder,
1213 failed_render,
1214 is_bot_wall,
1215 vendor_block,
1216 is_status_blocked,
1217 antibot,
1218 antibot_blocked,
1219 hard_block,
1220 acceptable,
1221 }
1222 }
1223
1224 #[allow(clippy::too_many_arguments)] async fn try_hedge(
1239 &self,
1240 lp: &Arc<dyn PageFetcher>,
1241 chrome: &Arc<dyn PageFetcher>,
1242 url: &str,
1243 headers: &HashMap<String, String>,
1244 wait_for_ms: Option<u64>,
1245 deadline: crw_core::Deadline,
1246 host: &str,
1247 ) -> CrwResult<Option<HedgeOutcome>> {
1248 let (lp_permit, lp_guard) = self
1251 .breakers
1252 .acquire_with_guard(host, RendererKind::Lightpanda)
1253 .await;
1254 if lp_permit == Permit::Rejected {
1255 drop(lp_guard);
1256 return Ok(None);
1257 }
1258 let (ch_permit, ch_guard) = self
1259 .breakers
1260 .acquire_with_guard(host, RendererKind::Chrome)
1261 .await;
1262 if ch_permit == Permit::Rejected {
1263 drop(lp_guard);
1264 drop(ch_guard);
1265 return Ok(None);
1266 }
1267 let mut lp_guard = Some(lp_guard);
1268 let mut ch_guard = Some(ch_guard);
1269
1270 let lp_fut = lp.fetch(url, headers, wait_for_ms, deadline);
1273 let chrome_fut = chrome.fetch(url, headers, wait_for_ms, deadline);
1274 tokio::pin!(lp_fut, chrome_fut);
1275 let (mut lp_done, mut ch_done) = (false, false);
1276 let mut lp_res: Option<CrwResult<FetchResult>> = None;
1277 let mut ch_res: Option<CrwResult<FetchResult>> = None;
1278 while !(lp_done && ch_done) {
1279 tokio::select! {
1280 biased;
1281 r = &mut lp_fut, if !lp_done => {
1282 lp_done = true;
1283 let accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1284 lp_res = Some(r);
1285 if accept {
1287 break;
1288 }
1289 }
1290 r = &mut chrome_fut, if !ch_done => {
1291 ch_done = true;
1292 let ch_accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1293 ch_res = Some(r);
1294 if lp_done {
1297 let lp_accept = matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1298 if !lp_accept && ch_accept {
1299 break;
1300 }
1301 }
1302 }
1303 }
1304 }
1305 let lp_accept =
1309 matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1310 let ch_accept =
1311 matches!(&ch_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1312
1313 if lp_accept {
1315 let mut r = lp_res.unwrap().unwrap();
1316 self.record_hedge_success(host, RendererKind::Lightpanda, &r, &mut lp_guard)
1317 .await;
1318 r.credit_cost = credit_for(RendererKind::Lightpanda);
1320 r.render_decision = Some(RenderDecision::AutoDefault {
1321 chosen: RendererKind::Lightpanda,
1322 });
1323 return Ok(Some(HedgeOutcome::Accepted(r)));
1324 }
1325 let mut saw_hard_block = false;
1327 if let Some(Ok(res)) = &lp_res {
1328 let cls = self.classify_js_attempt(res);
1329 saw_hard_block |= cls.hard_block;
1330 self.record_hedge_thin(host, RendererKind::Lightpanda, &cls, &mut lp_guard)
1331 .await;
1332 }
1333 if ch_accept {
1334 let mut r = ch_res.unwrap().unwrap();
1335 self.record_hedge_success(host, RendererKind::Chrome, &r, &mut ch_guard)
1336 .await;
1337 r.credit_cost = credit_for(RendererKind::Chrome);
1338 r.render_decision = Some(RenderDecision::Failover {
1339 chain: vec![RendererKind::Lightpanda, RendererKind::Chrome],
1340 reason: FailoverErrorKind::Other,
1341 });
1342 return Ok(Some(HedgeOutcome::Accepted(r)));
1343 }
1344 if let Some(Ok(res)) = &ch_res {
1346 let cls = self.classify_js_attempt(res);
1347 saw_hard_block |= cls.hard_block;
1348 self.record_hedge_thin(host, RendererKind::Chrome, &cls, &mut ch_guard)
1349 .await;
1350 }
1351
1352 let thin = [lp_res, ch_res]
1354 .into_iter()
1355 .flatten()
1356 .filter_map(|r| r.ok())
1357 .max_by_key(|r| r.html.len());
1358 match thin {
1359 Some(r) => Ok(Some(HedgeOutcome::Thin(r, saw_hard_block))),
1360 None => Ok(None),
1363 }
1364 }
1365
1366 async fn record_hedge_success(
1368 &self,
1369 host: &str,
1370 k: RendererKind,
1371 result: &FetchResult,
1372 guard: &mut Option<ProbeGuard>,
1373 ) {
1374 if !host.is_empty() {
1375 let outcome = if result.truncated {
1376 BreakerOutcome::Truncated
1377 } else {
1378 BreakerOutcome::Success
1379 };
1380 self.breakers.record_outcome(host, k, outcome).await;
1381 self.preferences.record_success(host).await;
1382 }
1383 if let Some(g) = guard.take() {
1384 g.disarm();
1385 }
1386 }
1387
1388 async fn record_hedge_thin(
1390 &self,
1391 host: &str,
1392 k: RendererKind,
1393 cls: &JsAttemptClass,
1394 guard: &mut Option<ProbeGuard>,
1395 ) {
1396 if !host.is_empty() {
1397 self.breakers
1398 .record_outcome(host, k, BreakerOutcome::RenderError)
1399 .await;
1400 if k == RendererKind::Lightpanda {
1401 let err_kind = if cls.is_status_blocked || cls.is_bot_wall || cls.antibot_blocked {
1402 FailoverErrorKind::AntibotBlock
1403 } else {
1404 FailoverErrorKind::PlaceholderContent
1405 };
1406 let _ = self.preferences.record_failure(host, &err_kind).await;
1407 }
1408 }
1409 let _ = guard;
1411 }
1412
1413 async fn fetch_with_js(
1414 &self,
1415 url: &str,
1416 headers: &HashMap<String, String>,
1417 wait_for_ms: Option<u64>,
1418 requested_renderer: Option<&str>,
1419 deadline: crw_core::Deadline,
1420 ) -> CrwResult<FetchResult> {
1421 let host = host_of(url);
1422 let is_user_pinned = matches!(requested_renderer, Some(name) if name != "auto");
1423 if let Some(pinned) = requested_renderer
1424 && let Some(kind) = renderer_kind_for(pinned)
1425 {
1426 metrics()
1427 .user_pin_total
1428 .with_label_values(&[kind.as_str()])
1429 .inc();
1430 }
1431
1432 let mut renderers: Vec<&Arc<dyn PageFetcher>> = match requested_renderer {
1441 Some(name) if name != "auto" => self
1442 .js_renderers
1443 .iter()
1444 .filter(|r| r.name() == name)
1445 .collect(),
1446 _ => {
1447 #[cfg(feature = "camoufox")]
1448 {
1449 let in_auto = self.camoufox_in_auto;
1450 self.js_renderers
1451 .iter()
1452 .filter(|r| in_auto || r.name() != "camoufox")
1453 .collect()
1454 }
1455 #[cfg(not(feature = "camoufox"))]
1456 {
1457 self.js_renderers.iter().collect()
1458 }
1459 }
1460 };
1461
1462 let proxy_active = REQUEST_PROXY.try_with(|p| p.is_some()).unwrap_or(false);
1469 if proxy_active {
1470 renderers.retain(|r| r.name() != "lightpanda");
1471 if renderers.is_empty() {
1472 return Err(CrwError::RendererError(
1473 "a proxy is required for this request but the only available JS \
1474 renderer (lightpanda) cannot route through a proxy; configure a \
1475 chrome/chrome_proxy tier to use proxies with JS rendering"
1476 .into(),
1477 ));
1478 }
1479 }
1480
1481 if screenshot_requested() {
1489 renderers.retain(|r| r.name() != "lightpanda" && r.name() != "camoufox");
1490 if renderers.is_empty() {
1491 return Err(CrwError::RendererError(
1492 "a screenshot was requested but no CDP-capable Chrome renderer is \
1493 available; lightpanda and camoufox cannot capture screenshots — \
1494 configure a chrome/chrome_proxy tier"
1495 .into(),
1496 ));
1497 }
1498 }
1499 let auto_egress_arm: Option<Arc<dyn PageFetcher>> =
1508 if self.auto_egress_escalation && !is_user_pinned && !proxy_active {
1509 let arm = self
1510 .js_renderers
1511 .iter()
1512 .find(|r| r.name() == "chrome_proxy")
1513 .cloned();
1514 renderers.retain(|r| r.name() != "chrome_proxy");
1515 arm
1516 } else {
1517 None
1518 };
1519
1520 if !is_user_pinned
1522 && let Some(RendererKind::Chrome) = self.preferences.preferred(&host).await
1523 {
1524 renderers.sort_by_key(|r| match r.name() {
1530 "chrome" => 0,
1531 "chrome_proxy" => 1,
1532 _ => 2,
1533 });
1534 tracing::debug!(host = %host, "host promoted to chrome by preference learner");
1535 }
1536
1537 if renderers.is_empty() {
1538 let available = self.js_renderer_names();
1539 return Err(CrwError::RendererError(format!(
1540 "requested renderer '{}' not in pool [{}]",
1541 requested_renderer.unwrap_or("auto"),
1542 available.join(", ")
1543 )));
1544 }
1545
1546 let mut chain: Vec<RendererKind> = Vec::new();
1549 let mut breaker_skipped: Vec<RendererKind> = Vec::new();
1550 let mut last_error = None;
1551 let mut last_failover_reason: Option<FailoverErrorKind> = None;
1552 let mut thin_result: Option<FetchResult> = None;
1553 let mut saw_hard_block = false;
1558 let renderers_snapshot: Vec<&Arc<dyn PageFetcher>> = renderers.clone();
1563
1564 let mut hedge_done = false;
1570 if self.chrome_hedge
1571 && !is_user_pinned
1572 && !proxy_active
1573 && renderers.first().map(|r| r.name()) == Some("lightpanda")
1574 && renderers.iter().any(|r| r.name() == "chrome")
1575 && let Ok(_permit) = self.hedge_sem.clone().try_acquire_owned()
1576 {
1577 let lp = renderers
1578 .iter()
1579 .find(|r| r.name() == "lightpanda")
1580 .expect("checked above");
1581 let chrome = renderers
1582 .iter()
1583 .find(|r| r.name() == "chrome")
1584 .expect("checked above");
1585 match self
1586 .try_hedge(lp, chrome, url, headers, wait_for_ms, deadline, &host)
1587 .await
1588 {
1589 Ok(Some(HedgeOutcome::Accepted(r))) => return Ok(r),
1590 Ok(Some(HedgeOutcome::Thin(r, hb))) => {
1591 thin_result = Some(r);
1592 saw_hard_block |= hb;
1593 chain.push(RendererKind::Lightpanda);
1594 chain.push(RendererKind::Chrome);
1595 hedge_done = true;
1596 }
1597 Ok(None) => {}
1599 Err(e) => last_error = Some(e),
1600 }
1601 }
1602
1603 for renderer in renderers {
1604 if hedge_done {
1605 break;
1606 }
1607 let kind = renderer_kind_for(renderer.name());
1608
1609 let trackable = kind.filter(|_| !host.is_empty());
1612
1613 let mut probe_guard: Option<ProbeGuard> = None;
1624 if let Some(k) = trackable {
1625 let (permit, guard) = self.breakers.acquire_with_guard(&host, k).await;
1626 if permit == Permit::Rejected {
1627 tracing::info!(
1628 renderer = renderer.name(),
1629 host = %host,
1630 "circuit breaker open, skipping renderer"
1631 );
1632 metrics()
1633 .render_route_decision_total
1634 .with_label_values(&[k.as_str(), "breakerSkipped"])
1635 .inc();
1636 breaker_skipped.push(k);
1637 drop(guard); continue;
1639 }
1640 probe_guard = Some(guard);
1641 }
1642 if let Some(k) = kind {
1643 chain.push(k);
1644 }
1645
1646 let attempt_ctx = {
1649 let remaining = deadline.remaining();
1650 let tier_budget = kind
1651 .and_then(|k| self.tier_timeouts.get(&k).copied())
1652 .unwrap_or(remaining);
1653 AttemptContext::capture(remaining, tier_budget)
1654 };
1655 let attempt_start = std::time::Instant::now();
1662 let attempt_outcome = renderer.fetch(url, headers, wait_for_ms, deadline).await;
1663 if self.latency_breakdown {
1664 let attempt_ms = attempt_start.elapsed().as_millis() as u64;
1665 let tier = renderer.name();
1666 match &attempt_outcome {
1667 Ok(r) => tracing::info!(
1668 target: "latency_breakdown",
1669 url, tier, attempt_ms,
1670 status = r.status_code,
1671 html_len = r.html.len(),
1672 "hedge attempt"
1673 ),
1674 Err(e) => tracing::info!(
1675 target: "latency_breakdown",
1676 url, tier, attempt_ms,
1677 error = %e,
1678 "hedge attempt (error)"
1679 ),
1680 }
1681 }
1682 match attempt_outcome {
1683 Ok(mut result) => {
1684 let text_len = html_body_text_len(&result.html);
1685 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1686 let failed_render = detector::looks_like_failed_render(&result.html);
1687 let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1688 let vendor_block = detector::looks_like_vendor_block(&result.html);
1689 let is_status_blocked = matches!(
1693 result.status_code,
1694 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1695 );
1696 let antibot = if self.antibot.enabled {
1705 crw_extract::antibot::classify(Some(result.status_code), &result.html)
1706 } else {
1707 crw_extract::antibot::AntibotResult::none()
1708 };
1709 let antibot_blocked =
1710 self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1711 if matches!(result.status_code, 401 | 403 | 429 | 503)
1715 || (520..=530).contains(&result.status_code)
1716 || is_bot_wall
1717 || vendor_block.is_some()
1718 || antibot.signal.is_blocked()
1719 {
1720 saw_hard_block = true;
1721 }
1722 if text_len >= Self::MIN_RENDERED_TEXT_LEN
1723 && !is_placeholder
1724 && failed_render.is_none()
1725 && !is_bot_wall
1726 && vendor_block.is_none()
1727 && !is_status_blocked
1728 && !antibot_blocked
1729 {
1730 let was_promoted = matches!(
1734 self.preferences.preferred(&host).await,
1735 Some(RendererKind::Chrome)
1736 );
1737 if let Some(k) = trackable {
1738 let outcome = if result.truncated {
1741 BreakerOutcome::Truncated
1742 } else {
1743 BreakerOutcome::Success
1744 };
1745 self.breakers.record_outcome(&host, k, outcome).await;
1746 self.preferences.record_success(&host).await;
1747 metrics()
1748 .render_route_decision_total
1749 .with_label_values(&[k.as_str(), "success"])
1750 .inc();
1751 metrics()
1752 .host_preferences_size
1753 .set(self.preferences.size() as i64);
1754 }
1755 if let Some(g) = probe_guard.take() {
1756 g.disarm();
1757 }
1758 if let Some(k) = kind {
1760 result.credit_cost = credit_for(k);
1761 result.render_decision = Some(if is_user_pinned {
1762 RenderDecision::UserPinned { renderer: k }
1763 } else if !breaker_skipped.is_empty() {
1764 RenderDecision::BreakerSkipped {
1765 skipped: breaker_skipped[0],
1766 chosen: k,
1767 }
1768 } else if chain.len() > 1 {
1769 RenderDecision::Failover {
1770 chain: chain.clone(),
1771 reason: last_failover_reason
1772 .clone()
1773 .unwrap_or(FailoverErrorKind::Other),
1774 }
1775 } else if was_promoted && k == RendererKind::Chrome {
1776 RenderDecision::AutoPromoted {
1777 chosen: k,
1778 from: RendererKind::Lightpanda,
1779 reason: "host preference learner".into(),
1780 }
1781 } else {
1782 RenderDecision::AutoDefault { chosen: k }
1783 });
1784 }
1785 return Ok(result);
1786 }
1787 let err_kind = match failed_render {
1790 Some(detector::FailedRenderReason::NextJsClientError) => {
1791 FailoverErrorKind::NextJsClientError
1792 }
1793 Some(detector::FailedRenderReason::ReactMinifiedError) => {
1794 FailoverErrorKind::NextJsClientError
1795 }
1796 Some(detector::FailedRenderReason::EmptyNextRoot) => {
1797 FailoverErrorKind::EmptyNextRoot
1798 }
1799 None if vendor_block.is_some() => FailoverErrorKind::VendorBlock,
1800 None if is_status_blocked => FailoverErrorKind::StatusBlocked,
1801 None if is_placeholder => FailoverErrorKind::PlaceholderContent,
1802 None if is_bot_wall => FailoverErrorKind::PlaceholderContent,
1803 None if antibot_blocked => FailoverErrorKind::AntibotBlock,
1805 None => FailoverErrorKind::PlaceholderContent,
1806 };
1807 last_failover_reason = Some(err_kind.clone());
1808 if let Some(k) = trackable {
1809 let outcome = classify_outcome(false, false, false, &attempt_ctx);
1813 self.breakers.record_outcome(&host, k, outcome).await;
1814 if k == RendererKind::Lightpanda
1815 && let Some(target) =
1816 self.preferences.record_failure(&host, &err_kind).await
1817 {
1818 metrics()
1819 .host_preferences_promotions_total
1820 .with_label_values(&[k.as_str(), target.as_str()])
1821 .inc();
1822 tracing::info!(
1823 host = %host,
1824 "host promoted by preference learner: {} -> {}",
1825 k.as_str(),
1826 target.as_str()
1827 );
1828 }
1829 }
1830 if let Some(g) = probe_guard.take() {
1831 g.disarm();
1832 }
1833 if let Some(vendor) = vendor_block {
1834 metrics()
1835 .vendor_block_total
1836 .with_label_values(&[vendor])
1837 .inc();
1838 tracing::warn!(
1839 renderer = renderer.name(),
1840 url,
1841 vendor,
1842 "vendor anti-bot block detected"
1843 );
1844 }
1845 if antibot.signal.is_blocked() {
1848 metrics()
1849 .antibot_escalation_total
1850 .with_label_values(&[antibot.signal.class_name()])
1851 .inc();
1852 tracing::warn!(
1853 renderer = renderer.name(),
1854 url,
1855 signal = antibot.signal.class_name(),
1856 reason = %antibot.reason,
1857 status_code = result.status_code,
1858 text_len,
1859 escalated = antibot_blocked,
1860 "antibot classifier flagged a block"
1861 );
1862 }
1863 tracing::info!(
1864 renderer = renderer.name(),
1865 text_len,
1866 is_placeholder,
1867 is_bot_wall,
1868 vendor_block,
1869 is_status_blocked,
1870 antibot_signal = antibot.signal.class_name(),
1871 antibot_blocked,
1872 status_code = result.status_code,
1873 failed_render = ?failed_render,
1874 "JS renderer returned thin/placeholder/failed content, trying next renderer"
1875 );
1876 let mut annotated = result;
1883 let attempt_warning = if let Some(reason) = failed_render {
1884 format!(
1885 "{} returned a failed render ({})",
1886 renderer.name(),
1887 reason.as_str()
1888 )
1889 } else if is_placeholder {
1890 format!("{} returned a loading placeholder", renderer.name())
1891 } else if let Some(vendor) = vendor_block {
1892 format!(
1893 "{} returned a vendor anti-bot block ({vendor})",
1894 renderer.name()
1895 )
1896 } else if is_bot_wall {
1897 format!(
1898 "{} returned a generic anti-bot interstitial",
1899 renderer.name()
1900 )
1901 } else if is_status_blocked {
1902 format!(
1903 "{} returned HTTP {} (treated as blocked)",
1904 renderer.name(),
1905 annotated.status_code
1906 )
1907 } else if antibot_blocked {
1908 format!(
1909 "{} returned an anti-bot block ({}: {})",
1910 renderer.name(),
1911 antibot.signal.class_name(),
1912 antibot.reason
1913 )
1914 } else {
1915 format!(
1916 "{} returned thin content (text_len={text_len})",
1917 renderer.name()
1918 )
1919 };
1920 if is_bot_wall || vendor_block.is_some() || is_status_blocked || antibot_blocked
1921 {
1922 let msg = if let Some(v) = vendor_block {
1931 format!("{} returned a vendor anti-bot block ({v})", renderer.name())
1932 } else if is_status_blocked {
1933 format!(
1934 "{} returned HTTP {} (treated as blocked)",
1935 renderer.name(),
1936 annotated.status_code
1937 )
1938 } else if is_bot_wall {
1939 format!(
1940 "{} returned a generic anti-bot interstitial",
1941 renderer.name()
1942 )
1943 } else {
1944 format!(
1945 "{} returned an anti-bot block ({}: {})",
1946 renderer.name(),
1947 antibot.signal.class_name(),
1948 antibot.reason
1949 )
1950 };
1951 last_error = Some(CrwError::RendererError(msg));
1952 }
1953 annotated.warnings.push(attempt_warning.clone());
1954 annotated.warning = Some(match annotated.warning {
1955 Some(prev) => format!("{prev}; {attempt_warning}"),
1956 None => attempt_warning.clone(),
1957 });
1958 thin_result = Some(match thin_result {
1959 None => annotated,
1960 Some(existing) => {
1961 let (mut keeper, dropped) =
1968 if annotated.html.len() > existing.html.len() {
1969 (annotated, existing)
1970 } else {
1971 (existing, annotated)
1972 };
1973 keeper.warnings.push(attempt_warning.clone());
1974 keeper.warning = Some(match keeper.warning {
1975 Some(prev) => format!("{prev}; {attempt_warning}"),
1976 None => attempt_warning,
1977 });
1978 for w in dropped.warnings {
1981 if !keeper.warnings.contains(&w) {
1982 keeper.warnings.push(w);
1983 }
1984 }
1985 keeper
1986 }
1987 });
1988 }
1989 Err(e) => {
1990 tracing::warn!(renderer = renderer.name(), "JS renderer failed: {e}");
1991 let err_kind = classify_renderer_error(&e);
1992 last_failover_reason = Some(err_kind.clone());
1993 if let Some(k) = trackable {
1994 let was_timeout = matches!(e, CrwError::Timeout(_));
1995 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
1996 self.breakers.record_outcome(&host, k, outcome).await;
1997 if k == RendererKind::Lightpanda {
1998 let _ = self.preferences.record_failure(&host, &err_kind).await;
1999 }
2000 }
2001 if let Some(g) = probe_guard.take() {
2002 g.disarm();
2003 }
2004 last_error = Some(e);
2005 continue;
2006 }
2007 }
2008 }
2009 const LEAK_MIN_BUDGET: Duration = Duration::from_millis(500);
2027 if thin_result.is_none()
2028 && !breaker_skipped.is_empty()
2029 && !is_user_pinned
2030 && deadline.remaining() >= LEAK_MIN_BUDGET
2031 {
2032 for renderer in &renderers_snapshot {
2033 let kind = renderer_kind_for(renderer.name());
2034 let trackable = kind.filter(|_| !host.is_empty());
2035 let Some(k) = trackable else { continue };
2036 if !breaker_skipped.contains(&k) {
2037 continue;
2038 }
2039 let permit = self.breakers.try_acquire_host_only(&host, k).await;
2040 if permit == Permit::Rejected {
2041 continue;
2042 }
2043 tracing::info!(
2044 renderer = renderer.name(),
2045 host = %host,
2046 "global breaker open, host clean — leaking through one attempt"
2047 );
2048 metrics()
2049 .render_route_decision_total
2050 .with_label_values(&[k.as_str(), "leakThrough"])
2051 .inc();
2052 let attempt_ctx = {
2053 let remaining = deadline.remaining();
2054 let tier_budget = self.tier_timeouts.get(&k).copied().unwrap_or(remaining);
2055 AttemptContext::capture(remaining, tier_budget)
2056 };
2057 let res = renderer.fetch(url, headers, wait_for_ms, deadline).await;
2058 match res {
2059 Ok(mut result) => {
2060 let text_len = html_body_text_len(&result.html);
2061 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
2062 let failed_render = detector::looks_like_failed_render(&result.html);
2063 let truncated = result.truncated;
2064 let content_ok = text_len >= Self::MIN_RENDERED_TEXT_LEN
2065 && !is_placeholder
2066 && failed_render.is_none();
2067 let outcome = classify_outcome(content_ok, truncated, false, &attempt_ctx);
2068 self.breakers
2071 .record_scoped_outcome(&host, k, None, Some(outcome))
2072 .await;
2073 if content_ok {
2074 result.credit_cost = credit_for(k);
2075 result.render_decision =
2076 Some(RenderDecision::AutoDefault { chosen: k });
2077 return Ok(result);
2078 }
2079 last_error = Some(CrwError::RendererError(format!(
2082 "leak attempt on {} returned thin content (text_len={text_len})",
2083 renderer.name()
2084 )));
2085 break;
2086 }
2087 Err(e) => {
2088 let was_timeout = matches!(e, CrwError::Timeout(_));
2089 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
2090 self.breakers
2091 .record_scoped_outcome(&host, k, None, Some(outcome))
2092 .await;
2093 last_error = Some(e);
2094 break;
2095 }
2096 }
2097 }
2098 }
2099
2100 if let Some(arm) = auto_egress_arm {
2107 let kind = RendererKind::ChromeProxy;
2108 let tier_budget = self
2109 .tier_timeouts
2110 .get(&kind)
2111 .copied()
2112 .unwrap_or_else(|| std::time::Duration::from_secs(30));
2113 if saw_hard_block && deadline.remaining() >= tier_budget {
2114 chain.push(kind);
2115 let entry = self.pick_proxy_for_url(url);
2116 let attempt = REQUEST_PROXY
2117 .scope(entry, arm.fetch(url, headers, wait_for_ms, deadline))
2118 .await;
2119 match attempt {
2120 Ok(r) => {
2121 let r_text = html_body_text_len(&r.html);
2122 let r_ok = r_text >= Self::MIN_RENDERED_TEXT_LEN
2123 && detector::looks_like_failed_render(&r.html).is_none()
2124 && !detector::looks_like_loading_placeholder(&r.html);
2125 if !host.is_empty() {
2126 let outcome = if r_ok {
2127 BreakerOutcome::Success
2128 } else {
2129 BreakerOutcome::RenderError
2130 };
2131 self.breakers.record_outcome(&host, kind, outcome).await;
2132 }
2133 let better = r_ok
2140 && match &thin_result {
2141 Some(prev) => r.html.len() > prev.html.len(),
2142 None => true,
2143 };
2144 if self.latency_breakdown {
2145 tracing::info!(
2146 target: "latency_breakdown",
2147 url, tier = "chrome_proxy",
2148 ok = r_ok, consumed = better,
2149 "auto_egress fired"
2150 );
2151 }
2152 if better {
2153 thin_result = Some(r);
2154 }
2155 }
2156 Err(e) => {
2157 if !host.is_empty() {
2158 self.breakers
2159 .record_outcome(&host, kind, BreakerOutcome::ConnectionError)
2160 .await;
2161 }
2162 if self.latency_breakdown {
2163 tracing::info!(
2164 target: "latency_breakdown",
2165 url, tier = "chrome_proxy", error = %e,
2166 "auto_egress fired (error)"
2167 );
2168 }
2169 }
2170 }
2171 }
2172 }
2173
2174 if let Some(mut result) = thin_result {
2176 if let Some(last) = chain.last().copied() {
2179 result.credit_cost = credit_for(last);
2180 result.render_decision = Some(RenderDecision::Failover {
2181 chain: chain.clone(),
2182 reason: last_failover_reason
2183 .clone()
2184 .unwrap_or(FailoverErrorKind::Other),
2185 });
2186 }
2187 if is_user_pinned
2192 && chain.len() == 1
2193 && let Some(pinned) = chain.first().copied()
2194 {
2195 let reason = last_failover_reason
2196 .as_ref()
2197 .map(|r| r.as_str())
2198 .unwrap_or("unknown");
2199 let hint = format!(
2200 "Pinned renderer '{}' returned a failed render ({}). Content may be unreliable. Retry with renderer=\"chrome\" or omit the renderer field for auto-failover.",
2201 pinned.as_str(),
2202 reason,
2203 );
2204 result.warnings.push(hint);
2205 }
2206 Ok(result)
2207 } else {
2208 Err(last_error
2209 .unwrap_or_else(|| CrwError::RendererError("No JS renderer available".to_string())))
2210 }
2211 }
2212
2213 pub async fn check_health(&self) -> HashMap<String, bool> {
2215 let mut health = HashMap::new();
2216 health.insert("http".to_string(), self.http.is_available().await);
2217 for r in &self.js_renderers {
2218 health.insert(r.name().to_string(), r.is_available().await);
2219 }
2220 health
2221 }
2222}
2223
2224fn html_body_text_len(html: &str) -> usize {
2228 let body = if let Some(start) = html.find("<body") {
2230 let start = html[start..].find('>').map(|i| start + i + 1).unwrap_or(0);
2231 let end = html.find("</body>").unwrap_or(html.len());
2232 &html[start..end]
2233 } else {
2234 html
2235 };
2236 let mut in_tag = false;
2238 let mut text_len = 0;
2239 let mut prev_ws = true;
2240 for ch in body.chars() {
2241 if ch == '<' {
2242 in_tag = true;
2243 } else if ch == '>' {
2244 in_tag = false;
2245 } else if !in_tag {
2246 if ch.is_whitespace() {
2247 if !prev_ws {
2248 text_len += 1;
2249 prev_ws = true;
2250 }
2251 } else {
2252 text_len += 1;
2253 prev_ws = false;
2254 }
2255 }
2256 }
2257 text_len
2258}
2259
2260#[cfg(test)]
2261mod tests {
2262 use super::*;
2263 use crate::breaker::BreakerConfig;
2264 #[cfg(feature = "camoufox")]
2265 use crw_core::config::CamoufoxEndpoint;
2266 #[cfg(feature = "cdp")]
2267 use crw_core::config::CdpEndpoint;
2268 use std::time::Duration;
2269
2270 fn tdl() -> crw_core::Deadline {
2272 crw_core::Deadline::now_plus(Duration::from_secs(60))
2273 }
2274
2275 fn base_cfg(mode: RendererMode) -> RendererConfig {
2276 RendererConfig {
2277 mode,
2278 ..Default::default()
2279 }
2280 }
2281
2282 #[test]
2283 fn new_mode_none_ok_no_js_renderers() {
2284 let cfg = base_cfg(RendererMode::None);
2285 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2286 assert!(r.js_renderer_names().is_empty());
2287 assert_eq!(r.render_js_default, None);
2288 }
2289
2290 #[test]
2291 fn new_mode_auto_no_endpoints_ok_http_only() {
2292 let cfg = base_cfg(RendererMode::Auto);
2293 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2294 assert!(r.js_renderer_names().is_empty());
2295 }
2296
2297 #[cfg(feature = "cdp")]
2298 #[test]
2299 fn new_mode_chrome_without_endpoint_errors() {
2300 let cfg = base_cfg(RendererMode::Chrome);
2301 let err =
2302 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2303 let msg = err.to_string().to_lowercase();
2304 assert!(msg.contains("chrome"), "expected chrome in error: {msg}");
2305 assert!(
2306 msg.contains("ws_url") || msg.contains("not configured"),
2307 "expected ws_url hint in error: {msg}"
2308 );
2309 }
2310
2311 #[cfg(feature = "cdp")]
2312 #[test]
2313 fn new_mode_chrome_with_endpoint_ok_only_chrome() {
2314 let cfg = RendererConfig {
2315 mode: RendererMode::Chrome,
2316 chrome: Some(CdpEndpoint {
2317 ws_url: "ws://127.0.0.1:9222/".into(),
2318 }),
2319 lightpanda: Some(CdpEndpoint {
2320 ws_url: "ws://127.0.0.1:9223/".into(),
2321 }),
2322 ..Default::default()
2323 };
2324 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2325 assert_eq!(r.js_renderer_names(), vec!["chrome"]);
2326 }
2327
2328 #[cfg(feature = "cdp")]
2329 #[test]
2330 fn new_mode_lightpanda_without_endpoint_errors() {
2331 let cfg = base_cfg(RendererMode::Lightpanda);
2332 let err =
2333 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2334 assert!(err.to_string().to_lowercase().contains("lightpanda"));
2335 }
2336
2337 #[cfg(feature = "cdp")]
2338 #[test]
2339 fn new_mode_auto_with_both_endpoints_preserves_order() {
2340 let cfg = RendererConfig {
2341 mode: RendererMode::Auto,
2342 lightpanda: Some(CdpEndpoint {
2343 ws_url: "ws://127.0.0.1:9222/".into(),
2344 }),
2345 chrome: Some(CdpEndpoint {
2346 ws_url: "ws://127.0.0.1:9223/".into(),
2347 }),
2348 ..Default::default()
2349 };
2350 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2351 assert_eq!(r.js_renderer_names(), vec!["lightpanda", "chrome"]);
2352 }
2353
2354 #[cfg(feature = "cdp")]
2355 #[test]
2356 fn ladder_includes_chrome_proxy_when_configured() {
2357 let cfg = RendererConfig {
2358 mode: RendererMode::Auto,
2359 lightpanda: Some(CdpEndpoint {
2360 ws_url: "ws://127.0.0.1:9222/".into(),
2361 }),
2362 chrome: Some(CdpEndpoint {
2363 ws_url: "ws://127.0.0.1:9223/".into(),
2364 }),
2365 chrome_proxy: Some(CdpEndpoint {
2366 ws_url: "ws://127.0.0.1:9224/".into(),
2367 }),
2368 ..Default::default()
2369 };
2370 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2371 assert_eq!(
2374 r.js_renderer_names(),
2375 vec!["lightpanda", "chrome", "chrome_proxy"]
2376 );
2377 }
2378
2379 #[cfg(feature = "cdp")]
2380 #[test]
2381 fn ladder_omits_chrome_proxy_when_not_configured() {
2382 let cfg = RendererConfig {
2383 mode: RendererMode::Auto,
2384 chrome: Some(CdpEndpoint {
2385 ws_url: "ws://127.0.0.1:9223/".into(),
2386 }),
2387 chrome_proxy: None,
2388 ..Default::default()
2389 };
2390 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2391 assert!(!r.js_renderer_names().contains(&"chrome_proxy"));
2392 }
2393
2394 #[cfg(not(feature = "cdp"))]
2395 #[test]
2396 fn new_mode_chrome_errors_without_cdp_feature() {
2397 let cfg = base_cfg(RendererMode::Chrome);
2398 let err =
2399 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2400 let msg = err.to_string().to_lowercase();
2401 assert!(msg.contains("cdp"), "expected cdp in error: {msg}");
2402 }
2403
2404 #[cfg(feature = "camoufox")]
2405 fn camoufox_cfg(mode: RendererMode, include_in_auto: bool) -> RendererConfig {
2406 RendererConfig {
2407 mode,
2408 camoufox: Some(CamoufoxEndpoint {
2409 base_url: "http://127.0.0.1:9377".into(),
2410 api_key: String::new(),
2411 include_in_auto,
2412 }),
2413 ..Default::default()
2414 }
2415 }
2416
2417 #[cfg(feature = "camoufox")]
2421 #[test]
2422 fn camoufox_constructed_for_pin_but_excluded_from_auto() {
2423 let cfg = camoufox_cfg(RendererMode::Auto, false);
2424 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2425 assert!(
2426 r.js_renderer_names().contains(&"camoufox"),
2427 "configured camoufox must be constructed for pin-reachability"
2428 );
2429 assert!(
2430 !r.camoufox_in_auto,
2431 "include_in_auto=false must keep camoufox out of the auto ladder"
2432 );
2433 }
2434
2435 #[cfg(feature = "camoufox")]
2436 #[test]
2437 fn camoufox_joins_auto_when_include_in_auto_true() {
2438 let cfg = camoufox_cfg(RendererMode::Auto, true);
2439 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2440 assert!(r.js_renderer_names().contains(&"camoufox"));
2441 assert!(r.camoufox_in_auto);
2442 }
2443
2444 #[cfg(feature = "camoufox")]
2447 #[test]
2448 fn camoufox_pinned_mode_uses_only_camoufox() {
2449 let cfg = camoufox_cfg(RendererMode::Camoufox, false);
2450 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2451 assert_eq!(r.js_renderer_names(), vec!["camoufox"]);
2452 assert!(r.camoufox_in_auto);
2453 }
2454
2455 #[cfg(feature = "camoufox")]
2456 #[test]
2457 fn camoufox_pinned_mode_without_base_url_errors() {
2458 let cfg = RendererConfig {
2459 mode: RendererMode::Camoufox,
2460 camoufox: Some(CamoufoxEndpoint::default()), ..Default::default()
2462 };
2463 let err =
2464 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2465 assert!(err.to_string().to_lowercase().contains("camoufox"));
2466 }
2467
2468 #[cfg(feature = "camoufox")]
2469 #[test]
2470 fn camoufox_absent_when_not_configured() {
2471 let cfg = base_cfg(RendererMode::Auto);
2472 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2473 assert!(!r.js_renderer_names().contains(&"camoufox"));
2474 assert!(!r.camoufox_in_auto);
2475 }
2476
2477 #[test]
2478 fn new_render_js_default_stored() {
2479 let cfg = RendererConfig {
2480 mode: RendererMode::None,
2481 render_js_default: Some(true),
2482 ..Default::default()
2483 };
2484 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2485 assert_eq!(r.render_js_default, Some(true));
2486 }
2487
2488 struct MockFetcher {
2490 name: &'static str,
2491 behavior: MockBehavior,
2492 }
2493
2494 #[derive(Clone)]
2495 enum MockBehavior {
2496 Ok(String),
2497 OkStatus(u16, String),
2498 Err(String),
2499 }
2500
2501 #[async_trait::async_trait]
2502 impl PageFetcher for MockFetcher {
2503 async fn fetch(
2504 &self,
2505 url: &str,
2506 _headers: &HashMap<String, String>,
2507 _wait_for_ms: Option<u64>,
2508 _deadline: crw_core::Deadline,
2509 ) -> CrwResult<FetchResult> {
2510 let (status, html) = match &self.behavior {
2511 MockBehavior::Ok(html) => (200u16, html.clone()),
2512 MockBehavior::OkStatus(s, html) => (*s, html.clone()),
2513 MockBehavior::Err(msg) => return Err(CrwError::RendererError(msg.clone())),
2514 };
2515 Ok(FetchResult {
2516 url: url.to_string(),
2517 final_url: None,
2518 status_code: status,
2519 html,
2520 content_type: Some("text/html".to_string()),
2521 raw_bytes: None,
2522 rendered_with: Some(self.name.to_string()),
2523 elapsed_ms: 0,
2524 warning: None,
2525 render_decision: None,
2526 credit_cost: 0,
2527 warnings: Vec::new(),
2528 truncated: false,
2529 deadline_exceeded: false,
2530 captured_responses: Vec::new(),
2531 screenshot: None,
2532 })
2533 }
2534
2535 fn name(&self) -> &str {
2536 self.name
2537 }
2538 fn supports_js(&self) -> bool {
2539 true
2540 }
2541 async fn is_available(&self) -> bool {
2542 true
2543 }
2544 }
2545
2546 fn rich_html(marker: &str) -> String {
2547 format!(
2548 "<html><body><article>{}{}</article></body></html>",
2549 marker,
2550 "x".repeat(200)
2551 )
2552 }
2553
2554 fn make_renderer_with_mocks(mocks: Vec<Arc<dyn PageFetcher>>) -> FallbackRenderer {
2555 let cfg = base_cfg(RendererMode::None);
2557 let mut r =
2558 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2559 r.js_renderers = mocks;
2560 r
2561 }
2562
2563 #[tokio::test]
2564 async fn proxy_active_lightpanda_only_fails_closed() {
2565 let lp = Arc::new(MockFetcher {
2568 name: "lightpanda",
2569 behavior: MockBehavior::Ok(rich_html("LP-")),
2570 }) as Arc<dyn PageFetcher>;
2571 let r = make_renderer_with_mocks(vec![lp]);
2572 let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2573 let res = REQUEST_PROXY
2576 .scope(Some(entry), async {
2577 r.fetch_with_js(
2578 "https://example.com",
2579 &HashMap::new(),
2580 None,
2581 None,
2582 crw_core::Deadline::from_request_ms(5000),
2583 )
2584 .await
2585 })
2586 .await;
2587 assert!(
2588 res.is_err(),
2589 "lightpanda-only + proxy active must fail closed, got {res:?}"
2590 );
2591 }
2592
2593 #[tokio::test]
2594 async fn proxy_active_prefers_chrome_over_lightpanda() {
2595 let lp = Arc::new(MockFetcher {
2598 name: "lightpanda",
2599 behavior: MockBehavior::Ok(rich_html("LP-")),
2600 }) as Arc<dyn PageFetcher>;
2601 let chrome = Arc::new(MockFetcher {
2602 name: "chrome",
2603 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2604 }) as Arc<dyn PageFetcher>;
2605 let r = make_renderer_with_mocks(vec![lp, chrome]);
2606 let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2607 let res = REQUEST_PROXY
2608 .scope(Some(entry), async {
2609 r.fetch_with_js(
2610 "https://example.com",
2611 &HashMap::new(),
2612 None,
2613 None,
2614 crw_core::Deadline::from_request_ms(5000),
2615 )
2616 .await
2617 })
2618 .await
2619 .unwrap();
2620 assert_eq!(res.rendered_with.as_deref(), Some("chrome"));
2621 }
2622
2623 #[tokio::test]
2624 async fn fetch_with_pinned_renderer_filters_pool() {
2625 let lp = Arc::new(MockFetcher {
2626 name: "lightpanda",
2627 behavior: MockBehavior::Ok(rich_html("LP-")),
2628 }) as Arc<dyn PageFetcher>;
2629 let chrome = Arc::new(MockFetcher {
2630 name: "chrome",
2631 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2632 }) as Arc<dyn PageFetcher>;
2633 let r = make_renderer_with_mocks(vec![lp, chrome]);
2634
2635 let result = r
2636 .fetch(
2637 "https://example.com",
2638 &HashMap::new(),
2639 Some(true),
2640 None,
2641 Some("chrome"),
2642 tdl(),
2643 )
2644 .await
2645 .unwrap();
2646 assert!(result.html.contains("CHROME-"), "expected chrome output");
2647 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2648 }
2649
2650 #[tokio::test]
2651 async fn fetch_with_pinned_renderer_unknown_returns_error() {
2652 let chrome = Arc::new(MockFetcher {
2653 name: "chrome",
2654 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2655 }) as Arc<dyn PageFetcher>;
2656 let r = make_renderer_with_mocks(vec![chrome]);
2657
2658 let err = r
2659 .fetch(
2660 "https://example.com",
2661 &HashMap::new(),
2662 Some(true),
2663 None,
2664 Some("lightpanda"),
2665 tdl(),
2666 )
2667 .await
2668 .unwrap_err();
2669 let msg = err.to_string();
2670 assert!(
2671 msg.contains("lightpanda") && msg.contains("chrome"),
2672 "expected error to name pinned + available: {msg}"
2673 );
2674 }
2675
2676 #[tokio::test]
2677 async fn fetch_with_renderer_auto_uses_full_chain() {
2678 let lp = Arc::new(MockFetcher {
2679 name: "lightpanda",
2680 behavior: MockBehavior::Ok(rich_html("LP-")),
2681 }) as Arc<dyn PageFetcher>;
2682 let chrome = Arc::new(MockFetcher {
2683 name: "chrome",
2684 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2685 }) as Arc<dyn PageFetcher>;
2686 let r = make_renderer_with_mocks(vec![lp, chrome]);
2687
2688 let result = r
2689 .fetch(
2690 "https://example.com",
2691 &HashMap::new(),
2692 Some(true),
2693 None,
2694 Some("auto"),
2695 tdl(),
2696 )
2697 .await
2698 .unwrap();
2699 assert!(result.html.contains("LP-"), "expected lightpanda first");
2701 }
2702
2703 #[tokio::test]
2704 async fn failover_skips_renderer_that_returns_failed_render() {
2705 let bad_lp_html = format!(
2708 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2709 "x".repeat(200)
2710 );
2711 let lp = Arc::new(MockFetcher {
2712 name: "lightpanda",
2713 behavior: MockBehavior::Ok(bad_lp_html),
2714 }) as Arc<dyn PageFetcher>;
2715 let chrome = Arc::new(MockFetcher {
2716 name: "chrome",
2717 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2718 }) as Arc<dyn PageFetcher>;
2719 let r = make_renderer_with_mocks(vec![lp, chrome]);
2720
2721 let result = r
2722 .fetch(
2723 "https://example.com",
2724 &HashMap::new(),
2725 Some(true),
2726 None,
2727 None,
2728 tdl(),
2729 )
2730 .await
2731 .unwrap();
2732 assert!(result.html.contains("CHROME-OK"));
2733 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2734 }
2735
2736 #[tokio::test]
2737 async fn failover_surfaces_warning_when_only_failed_render_available() {
2738 let bad_lp_html = format!(
2742 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2743 "x".repeat(200)
2744 );
2745 let lp = Arc::new(MockFetcher {
2746 name: "lightpanda",
2747 behavior: MockBehavior::Ok(bad_lp_html),
2748 }) as Arc<dyn PageFetcher>;
2749 let r = make_renderer_with_mocks(vec![lp]);
2750
2751 let result = r
2752 .fetch(
2753 "https://example.com",
2754 &HashMap::new(),
2755 Some(true),
2756 None,
2757 None,
2758 tdl(),
2759 )
2760 .await
2761 .unwrap();
2762 let warning = result.warning.expect("expected warning to be set");
2763 assert!(
2764 warning.contains("lightpanda") && warning.contains("nextjs_client_error"),
2765 "warning should name renderer + reason: {warning}"
2766 );
2767 }
2768
2769 #[tokio::test]
2770 async fn failover_concats_warnings_across_two_failed_renderers() {
2771 let bad_lp_html = format!(
2775 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2776 "x".repeat(200)
2777 );
2778 let bad_chrome_html = format!(
2779 "<html><body><div id=\"__next_error__\">{}</div></body></html>",
2780 "y".repeat(200)
2781 );
2782 let lp = Arc::new(MockFetcher {
2783 name: "lightpanda",
2784 behavior: MockBehavior::Ok(bad_lp_html),
2785 }) as Arc<dyn PageFetcher>;
2786 let chrome = Arc::new(MockFetcher {
2787 name: "chrome",
2788 behavior: MockBehavior::Ok(bad_chrome_html),
2789 }) as Arc<dyn PageFetcher>;
2790 let r = make_renderer_with_mocks(vec![lp, chrome]);
2791
2792 let result = r
2793 .fetch(
2794 "https://example.com",
2795 &HashMap::new(),
2796 Some(true),
2797 None,
2798 None,
2799 tdl(),
2800 )
2801 .await
2802 .unwrap();
2803 let warning = result.warning.expect("expected warning to be set");
2804 assert!(
2805 warning.contains("lightpanda") && warning.contains("chrome"),
2806 "warning should mention both renderers: {warning}"
2807 );
2808 }
2809
2810 #[tokio::test]
2811 async fn fetch_pinned_renderer_failure_propagates() {
2812 let chrome = Arc::new(MockFetcher {
2813 name: "chrome",
2814 behavior: MockBehavior::Err("boom".into()),
2815 }) as Arc<dyn PageFetcher>;
2816 let r = make_renderer_with_mocks(vec![chrome]);
2817
2818 let err = r
2819 .fetch(
2820 "https://example.com",
2821 &HashMap::new(),
2822 Some(true),
2823 None,
2824 Some("chrome"),
2825 tdl(),
2826 )
2827 .await
2828 .unwrap_err();
2829 assert!(err.to_string().contains("boom"));
2830 }
2831
2832 #[tokio::test]
2833 async fn auto_promoted_host_tries_chrome_first() {
2834 let lp = Arc::new(MockFetcher {
2838 name: "lightpanda",
2839 behavior: MockBehavior::Ok(rich_html("LP-")),
2840 }) as Arc<dyn PageFetcher>;
2841 let chrome = Arc::new(MockFetcher {
2842 name: "chrome",
2843 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2844 }) as Arc<dyn PageFetcher>;
2845 let r = make_renderer_with_mocks(vec![lp, chrome]);
2846
2847 for _ in 0..3 {
2849 r.preferences
2850 .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
2851 .await;
2852 }
2853
2854 let result = r
2855 .fetch(
2856 "https://example.com",
2857 &HashMap::new(),
2858 Some(true),
2859 None,
2860 None,
2861 tdl(),
2862 )
2863 .await
2864 .unwrap();
2865 assert!(
2866 result.html.contains("CHROME-"),
2867 "promoted host should hit chrome first, got: {}",
2868 &result.html[..80.min(result.html.len())]
2869 );
2870 assert_eq!(result.credit_cost, 1, "every renderer costs 1 credit");
2871 assert!(matches!(
2872 result.render_decision,
2873 Some(RenderDecision::AutoPromoted {
2874 chosen: RendererKind::Chrome,
2875 ..
2876 })
2877 ));
2878 }
2879
2880 #[tokio::test]
2881 async fn breaker_skipped_renderer_falls_through_to_next() {
2882 let lp = Arc::new(MockFetcher {
2885 name: "lightpanda",
2886 behavior: MockBehavior::Err("would fire if reached".into()),
2887 }) as Arc<dyn PageFetcher>;
2888 let chrome = Arc::new(MockFetcher {
2889 name: "chrome",
2890 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2891 }) as Arc<dyn PageFetcher>;
2892 let mut r = make_renderer_with_mocks(vec![lp, chrome]);
2893
2894 let breaker_cfg = BreakerConfig {
2900 base_cooldown: Duration::from_secs(300),
2901 max_cooldown: Duration::from_secs(300),
2902 ..BreakerConfig::default()
2903 };
2904 r.breakers = Arc::new(BreakerRegistry::new(breaker_cfg));
2905 for _ in 0..80 {
2906 r.breakers
2907 .record_result("example.com", RendererKind::Lightpanda, false)
2908 .await;
2909 }
2910
2911 let result = r
2912 .fetch(
2913 "https://example.com",
2914 &HashMap::new(),
2915 Some(true),
2916 None,
2917 None,
2918 tdl(),
2919 )
2920 .await
2921 .unwrap();
2922 assert!(result.html.contains("CHROME-OK"));
2923 assert!(matches!(
2924 result.render_decision,
2925 Some(RenderDecision::BreakerSkipped {
2926 skipped: RendererKind::Lightpanda,
2927 chosen: RendererKind::Chrome
2928 })
2929 ));
2930 }
2931
2932 #[tokio::test]
2933 async fn user_pinned_failed_render_emits_warning() {
2934 let bad_html = format!(
2939 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2940 "x".repeat(200)
2941 );
2942 let lp = Arc::new(MockFetcher {
2943 name: "lightpanda",
2944 behavior: MockBehavior::Ok(bad_html),
2945 }) as Arc<dyn PageFetcher>;
2946 let chrome = Arc::new(MockFetcher {
2947 name: "chrome",
2948 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2949 }) as Arc<dyn PageFetcher>;
2950 let r = make_renderer_with_mocks(vec![lp, chrome]);
2951
2952 let result = r
2953 .fetch(
2954 "https://example.com",
2955 &HashMap::new(),
2956 Some(true),
2957 None,
2958 Some("lightpanda"),
2959 tdl(),
2960 )
2961 .await
2962 .unwrap();
2963 let pin_hint = result
2964 .warnings
2965 .iter()
2966 .find(|w| w.starts_with("Pinned renderer 'lightpanda'"));
2967 assert!(
2968 pin_hint.is_some(),
2969 "expected pin-failure hint in warnings, got: {:?}",
2970 result.warnings
2971 );
2972 let hint = pin_hint.unwrap();
2973 assert!(
2974 hint.contains("nextJsClientError"),
2975 "hint should name camelCase reason: {hint}"
2976 );
2977 assert!(
2978 hint.contains("renderer=\"chrome\""),
2979 "hint should suggest a fix: {hint}"
2980 );
2981 assert!(matches!(
2983 result.render_decision,
2984 Some(RenderDecision::Failover { ref chain, .. }) if chain.len() == 1
2985 ));
2986 }
2987
2988 #[tokio::test]
2989 async fn user_pinned_decision_records_credit_and_kind() {
2990 let chrome = Arc::new(MockFetcher {
2991 name: "chrome",
2992 behavior: MockBehavior::Ok(rich_html("CHROME-")),
2993 }) as Arc<dyn PageFetcher>;
2994 let r = make_renderer_with_mocks(vec![chrome]);
2995 let result = r
2996 .fetch(
2997 "https://example.com",
2998 &HashMap::new(),
2999 Some(true),
3000 None,
3001 Some("chrome"),
3002 tdl(),
3003 )
3004 .await
3005 .unwrap();
3006 assert_eq!(result.credit_cost, 1);
3007 assert!(matches!(
3008 result.render_decision,
3009 Some(RenderDecision::UserPinned {
3010 renderer: RendererKind::Chrome
3011 })
3012 ));
3013 }
3014
3015 #[tokio::test]
3016 async fn js_tier_escalates_on_403_status() {
3017 let lp = Arc::new(MockFetcher {
3020 name: "lightpanda",
3021 behavior: MockBehavior::OkStatus(403, rich_html("BLOCKED-")),
3022 }) as Arc<dyn PageFetcher>;
3023 let chrome = Arc::new(MockFetcher {
3024 name: "chrome",
3025 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3026 }) as Arc<dyn PageFetcher>;
3027 let r = make_renderer_with_mocks(vec![lp, chrome]);
3028
3029 let result = r
3030 .fetch(
3031 "https://example.com",
3032 &HashMap::new(),
3033 Some(true),
3034 None,
3035 Some("auto"),
3036 tdl(),
3037 )
3038 .await
3039 .unwrap();
3040 assert!(
3041 result.html.contains("CHROME-"),
3042 "expected chrome output after lightpanda 403"
3043 );
3044 assert_eq!(result.status_code, 200);
3045 }
3046
3047 #[tokio::test]
3048 async fn js_tier_escalates_on_vendor_block_with_200() {
3049 let cf_html = format!(
3052 "<html><head><script src=\"/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1\"></script></head><body>{}</body></html>",
3053 "x".repeat(200)
3054 );
3055 let lp = Arc::new(MockFetcher {
3056 name: "lightpanda",
3057 behavior: MockBehavior::Ok(cf_html),
3058 }) as Arc<dyn PageFetcher>;
3059 let chrome = Arc::new(MockFetcher {
3060 name: "chrome",
3061 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3062 }) as Arc<dyn PageFetcher>;
3063 let r = make_renderer_with_mocks(vec![lp, chrome]);
3064
3065 let result = r
3066 .fetch(
3067 "https://example.com",
3068 &HashMap::new(),
3069 Some(true),
3070 None,
3071 Some("auto"),
3072 tdl(),
3073 )
3074 .await
3075 .unwrap();
3076 assert!(
3077 result.html.contains("CHROME-"),
3078 "expected chrome output after lightpanda vendor block"
3079 );
3080 }
3081
3082 #[tokio::test]
3083 async fn js_tier_accepts_200_clean_response() {
3084 let lp = Arc::new(MockFetcher {
3087 name: "lightpanda",
3088 behavior: MockBehavior::Ok(rich_html("LP-CLEAN-")),
3089 }) as Arc<dyn PageFetcher>;
3090 let chrome = Arc::new(MockFetcher {
3091 name: "chrome",
3092 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3093 }) as Arc<dyn PageFetcher>;
3094 let r = make_renderer_with_mocks(vec![lp, chrome]);
3095
3096 let result = r
3097 .fetch(
3098 "https://example.com",
3099 &HashMap::new(),
3100 Some(true),
3101 None,
3102 Some("auto"),
3103 tdl(),
3104 )
3105 .await
3106 .unwrap();
3107 assert!(result.html.contains("LP-CLEAN-"));
3108 assert_eq!(result.status_code, 200);
3109 }
3110
3111 fn network_security_block_html() -> String {
3115 format!(
3116 "<html><body><article>You've been blocked by network security.{}</article></body></html>",
3117 "x".repeat(200)
3118 )
3119 }
3120
3121 #[tokio::test]
3122 async fn js_tier_escalates_to_chrome_proxy_on_antibot_block() {
3123 let lp = Arc::new(MockFetcher {
3126 name: "lightpanda",
3127 behavior: MockBehavior::Ok(network_security_block_html()),
3128 }) as Arc<dyn PageFetcher>;
3129 let chrome = Arc::new(MockFetcher {
3130 name: "chrome",
3131 behavior: MockBehavior::Ok(network_security_block_html()),
3132 }) as Arc<dyn PageFetcher>;
3133 let chrome_proxy = Arc::new(MockFetcher {
3134 name: "chrome_proxy",
3135 behavior: MockBehavior::Ok(rich_html("PROXY-")),
3136 }) as Arc<dyn PageFetcher>;
3137 let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3138
3139 let result = r
3140 .fetch(
3141 "https://example.com",
3142 &HashMap::new(),
3143 Some(true),
3144 None,
3145 Some("auto"),
3146 tdl(),
3147 )
3148 .await
3149 .unwrap();
3150 assert!(
3151 result.html.contains("PROXY-"),
3152 "expected chrome_proxy output after antibot block"
3153 );
3154 assert_eq!(
3155 result.render_decision,
3156 Some(RenderDecision::Failover {
3157 chain: vec![
3158 RendererKind::Lightpanda,
3159 RendererKind::Chrome,
3160 RendererKind::ChromeProxy,
3161 ],
3162 reason: FailoverErrorKind::AntibotBlock,
3163 })
3164 );
3165 }
3166
3167 #[tokio::test]
3168 async fn antibot_block_returns_as_success_when_escalation_disabled() {
3169 let lp = Arc::new(MockFetcher {
3173 name: "lightpanda",
3174 behavior: MockBehavior::Ok(network_security_block_html()),
3175 }) as Arc<dyn PageFetcher>;
3176 let chrome = Arc::new(MockFetcher {
3177 name: "chrome",
3178 behavior: MockBehavior::Ok(rich_html("CHROME-")),
3179 }) as Arc<dyn PageFetcher>;
3180 let mut r = make_renderer_with_mocks(vec![lp, chrome]);
3181 r.antibot.escalate_in_failover = false;
3182
3183 let result = r
3184 .fetch(
3185 "https://example.com",
3186 &HashMap::new(),
3187 Some(true),
3188 None,
3189 Some("auto"),
3190 tdl(),
3191 )
3192 .await
3193 .unwrap();
3194 assert!(
3195 result.html.contains("network security"),
3196 "block page should be returned as-is when escalation is disabled"
3197 );
3198 assert_eq!(result.rendered_with.as_deref(), Some("lightpanda"));
3199 }
3200
3201 #[tokio::test]
3202 async fn promoted_host_escalates_chrome_to_chrome_proxy_not_lightpanda() {
3203 let lp = Arc::new(MockFetcher {
3207 name: "lightpanda",
3208 behavior: MockBehavior::Ok(rich_html("LP-")),
3209 }) as Arc<dyn PageFetcher>;
3210 let chrome = Arc::new(MockFetcher {
3211 name: "chrome",
3212 behavior: MockBehavior::Ok(network_security_block_html()),
3213 }) as Arc<dyn PageFetcher>;
3214 let chrome_proxy = Arc::new(MockFetcher {
3215 name: "chrome_proxy",
3216 behavior: MockBehavior::Ok(rich_html("PROXY-")),
3217 }) as Arc<dyn PageFetcher>;
3218 let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3219
3220 for _ in 0..3 {
3222 r.preferences
3223 .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
3224 .await;
3225 }
3226
3227 let result = r
3228 .fetch(
3229 "https://example.com",
3230 &HashMap::new(),
3231 Some(true),
3232 None,
3233 None,
3234 tdl(),
3235 )
3236 .await
3237 .unwrap();
3238 assert!(
3239 result.html.contains("PROXY-"),
3240 "expected chrome_proxy output"
3241 );
3242 assert_eq!(
3243 result.render_decision,
3244 Some(RenderDecision::Failover {
3245 chain: vec![RendererKind::Chrome, RendererKind::ChromeProxy],
3246 reason: FailoverErrorKind::AntibotBlock,
3247 }),
3248 "chrome must escalate straight to chrome_proxy, skipping lightpanda"
3249 );
3250 }
3251}