1pub mod blocklist;
37pub mod breaker;
38#[cfg(feature = "auto-browser")]
39pub mod browser;
40#[cfg(feature = "cdp")]
41pub mod cdp;
42#[cfg(feature = "cdp")]
43pub mod cdp_conn;
44pub mod detector;
45#[cfg(feature = "cdp")]
46pub mod health_telemetry;
47pub mod host_limiter;
48pub mod http_only;
49pub mod preference;
50pub mod traits;
51
52use crate::breaker::{
53 AttemptContext, BreakerOutcome, BreakerRegistry, Permit, ProbeGuard, classify_outcome,
54};
55use crate::preference::HostPreferences;
56use crw_core::config::{BUILTIN_UA_POOL, RendererConfig, RendererMode, StealthConfig};
57use crw_core::error::{CrwError, CrwResult};
58use crw_core::metrics::metrics;
59use crw_core::types::{
60 FailoverErrorKind, FetchResult, RenderDecision, RendererKind, resolve_render_js,
61};
62use std::collections::HashMap;
63use std::sync::Arc;
64use std::time::Duration;
65use traits::PageFetcher;
66
67fn renderer_kind_for(name: &str) -> Option<RendererKind> {
71 match name {
72 "http" | "http_only_fallback" => Some(RendererKind::Http),
73 "lightpanda" => Some(RendererKind::Lightpanda),
74 "chrome" => Some(RendererKind::Chrome),
75 _ => None,
76 }
77}
78
79fn classify_renderer_error(err: &CrwError) -> FailoverErrorKind {
89 match err {
90 CrwError::Timeout(_) => FailoverErrorKind::LightpandaTimeout,
91 CrwError::TargetUnreachable(_) => FailoverErrorKind::NetworkError,
92 CrwError::HttpError(_) => FailoverErrorKind::NetworkError,
93 CrwError::RendererError(_) => FailoverErrorKind::LightpandaCrash,
96 _ => FailoverErrorKind::Other,
97 }
98}
99
100fn tier_timeouts_from(
103 config: &RendererConfig,
104) -> std::collections::HashMap<RendererKind, std::time::Duration> {
105 let mut m = std::collections::HashMap::new();
106 m.insert(
107 RendererKind::Http,
108 std::time::Duration::from_millis(config.http_timeout()),
109 );
110 m.insert(
111 RendererKind::Lightpanda,
112 std::time::Duration::from_millis(config.lightpanda_timeout()),
113 );
114 m.insert(
115 RendererKind::Chrome,
116 std::time::Duration::from_millis(config.chrome_timeout()),
117 );
118 m
119}
120
121fn credit_for(kind: RendererKind) -> u32 {
124 match kind {
125 RendererKind::Http => 1,
126 RendererKind::Lightpanda => 1,
127 RendererKind::Chrome => 2,
128 }
129}
130
131fn stamp_http_decision(result: &mut FetchResult, requested_renderer: Option<&str>) {
135 if result.render_decision.is_some() {
136 return;
137 }
138 let kind = RendererKind::Http;
139 result.credit_cost = credit_for(kind);
140 result.render_decision = Some(match requested_renderer {
141 Some("http") => RenderDecision::UserPinned { renderer: kind },
142 _ => RenderDecision::AutoDefault { chosen: kind },
143 });
144 metrics()
146 .render_route_decision_total
147 .with_label_values(&[kind.as_str(), "success"])
148 .inc();
149}
150
151fn host_of(url: &str) -> String {
153 url::Url::parse(url)
154 .ok()
155 .and_then(|u| u.host_str().map(|h| h.to_string()))
156 .unwrap_or_default()
157}
158
159fn pick_ua<'a>(default_ua: &'a str, stealth: &'a StealthConfig) -> String {
161 if stealth.enabled {
162 let pool: &[&str] = if stealth.user_agents.is_empty() {
163 BUILTIN_UA_POOL
164 } else {
165 return stealth.user_agents[rand::random_range(0..stealth.user_agents.len())].clone();
167 };
168 pool[rand::random_range(0..pool.len())].to_string()
169 } else {
170 default_ua.to_string()
171 }
172}
173
174pub struct FallbackRenderer {
176 http: Arc<dyn PageFetcher>,
177 js_renderers: Vec<Arc<dyn PageFetcher>>,
178 render_js_default: Option<bool>,
180 preferences: Arc<HostPreferences>,
182 breakers: Arc<BreakerRegistry>,
184 tier_timeouts: std::collections::HashMap<RendererKind, std::time::Duration>,
188 requests_per_second: f64,
192 per_host_max_concurrent: u32,
194}
195
196impl std::fmt::Debug for FallbackRenderer {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("FallbackRenderer")
199 .field("http", &self.http.name())
200 .field(
201 "js_renderers",
202 &self
203 .js_renderers
204 .iter()
205 .map(|r| r.name())
206 .collect::<Vec<_>>(),
207 )
208 .field("render_js_default", &self.render_js_default)
209 .finish()
210 }
211}
212
213impl FallbackRenderer {
214 pub fn new(
215 config: &RendererConfig,
216 user_agent: &str,
217 proxy: Option<&str>,
218 stealth: &StealthConfig,
219 ) -> CrwResult<Self> {
220 let effective_ua = pick_ua(user_agent, stealth);
221 let inject_headers = stealth.enabled && stealth.inject_headers;
222 let http = Arc::new(http_only::HttpFetcher::with_timeout(
223 &effective_ua,
224 proxy,
225 inject_headers,
226 std::time::Duration::from_millis(config.http_timeout()),
227 )) as Arc<dyn PageFetcher>;
228
229 #[cfg(not(feature = "cdp"))]
233 if matches!(
234 config.mode,
235 RendererMode::Lightpanda | RendererMode::Chrome | RendererMode::Playwright
236 ) {
237 return Err(CrwError::ConfigError(format!(
238 "renderer.mode = {:?} requires the 'cdp' feature, but this build was \
239 compiled without it. Rebuild with --features cdp or set mode = \"auto\"/\"none\".",
240 config.mode
241 )));
242 }
243
244 #[allow(unused_mut)]
245 let mut js_renderers: Vec<Arc<dyn PageFetcher>> = Vec::new();
246
247 if matches!(config.mode, RendererMode::None) {
248 if config.render_js_default == Some(true) {
249 tracing::warn!(
250 "render_js_default=true has no effect with mode=none; \
251 requests will fall back to HTTP via http_only_fallback"
252 );
253 }
254 return Ok(Self {
255 http,
256 js_renderers,
257 render_js_default: config.render_js_default,
258 preferences: Arc::new(HostPreferences::with_defaults()),
259 breakers: Arc::new(BreakerRegistry::with_defaults()),
260 tier_timeouts: tier_timeouts_from(config),
261 requests_per_second: 0.0,
262 per_host_max_concurrent: 1,
263 });
264 }
265
266 #[cfg(feature = "cdp")]
267 {
268 let want = |m: RendererMode| -> bool {
269 matches!(config.mode, RendererMode::Auto) || config.mode == m
270 };
271
272 if want(RendererMode::Lightpanda) {
273 if let Some(lp) = &config.lightpanda {
274 js_renderers.push(Arc::new(cdp::CdpRenderer::new(
275 "lightpanda",
276 &lp.ws_url,
277 config.lightpanda_timeout(),
278 config.pool_size,
279 )));
280 } else if matches!(config.mode, RendererMode::Lightpanda) {
281 return Err(CrwError::ConfigError(
282 "renderer.mode = \"lightpanda\" but [renderer.lightpanda] ws_url is not \
283 configured"
284 .into(),
285 ));
286 }
287 }
288 if want(RendererMode::Playwright) {
289 if let Some(pw) = &config.playwright {
290 js_renderers.push(Arc::new(cdp::CdpRenderer::new(
293 "playwright",
294 &pw.ws_url,
295 config.chrome_timeout(),
296 config.pool_size,
297 )));
298 } else if matches!(config.mode, RendererMode::Playwright) {
299 return Err(CrwError::ConfigError(
300 "renderer.mode = \"playwright\" but [renderer.playwright] ws_url is not \
301 configured"
302 .into(),
303 ));
304 }
305 }
306 if want(RendererMode::Chrome) {
307 if let Some(ch) = &config.chrome {
308 let blocklist = blocklist::Blocklist::defaults()
309 .with_stylesheets(config.chrome_intercept_stylesheets);
310 js_renderers.push(Arc::new(
311 cdp::CdpRenderer::new(
312 "chrome",
313 &ch.ws_url,
314 config.chrome_timeout(),
315 config.pool_size,
316 )
317 .with_nav_budget(config.chrome_nav_budget_ms)
318 .with_interception(
319 config.chrome_intercept_resources,
320 blocklist,
321 config.chrome_host_intercept_disable.clone(),
322 ),
323 ));
324 } else if matches!(config.mode, RendererMode::Chrome) {
325 return Err(CrwError::ConfigError(
326 "renderer.mode = \"chrome\" but [renderer.chrome] ws_url is not configured"
327 .into(),
328 ));
329 }
330 }
331 }
332
333 #[cfg(feature = "cdp")]
337 health_telemetry::spawn_once();
338
339 if config.render_js_default == Some(true) && js_renderers.is_empty() {
340 tracing::warn!(
341 "render_js_default=true but no JS renderer is available; \
342 requests will fall back to HTTP via http_only_fallback"
343 );
344 }
345
346 Ok(Self {
347 http,
348 js_renderers,
349 render_js_default: config.render_js_default,
350 preferences: Arc::new(HostPreferences::with_defaults()),
351 breakers: Arc::new(BreakerRegistry::with_defaults()),
352 tier_timeouts: tier_timeouts_from(config),
353 requests_per_second: 0.0,
354 per_host_max_concurrent: 1,
355 })
356 }
357
358 pub fn with_host_limits(
362 mut self,
363 requests_per_second: f64,
364 per_host_max_concurrent: u32,
365 ) -> Self {
366 self.requests_per_second = requests_per_second;
367 self.per_host_max_concurrent = per_host_max_concurrent;
368 self
369 }
370
371 pub fn preferences(&self) -> Arc<HostPreferences> {
373 Arc::clone(&self.preferences)
374 }
375
376 pub fn breakers(&self) -> Arc<BreakerRegistry> {
378 Arc::clone(&self.breakers)
379 }
380
381 pub fn js_renderer_names(&self) -> Vec<&str> {
384 self.js_renderers.iter().map(|r| r.name()).collect()
385 }
386
387 pub async fn fetch(
395 &self,
396 url: &str,
397 headers: &HashMap<String, String>,
398 render_js: Option<bool>,
399 wait_for_ms: Option<u64>,
400 requested_renderer: Option<&str>,
401 deadline: crw_core::Deadline,
402 ) -> CrwResult<FetchResult> {
403 let host_key = url::Url::parse(url)
407 .ok()
408 .and_then(|u| u.host_str().map(crate::preference::normalize_host));
409 let _host_permit = if let Some(key) = host_key.as_deref() {
410 let remaining = deadline.remaining();
411 if remaining.is_zero() {
412 return Err(CrwError::Timeout(
413 deadline.overrun().as_millis().max(1) as u64
414 ));
415 }
416 match tokio::time::timeout(
417 remaining,
418 crate::host_limiter::acquire(
419 key,
420 self.requests_per_second,
421 self.per_host_max_concurrent as usize,
422 ),
423 )
424 .await
425 {
426 Ok(Ok((permit, sleep))) => {
427 if !sleep.is_zero() {
428 let budget = deadline.remaining();
429 if sleep > budget {
430 return Err(CrwError::Timeout(sleep.as_millis().max(1) as u64));
431 }
432 tokio::time::sleep(sleep).await;
433 }
434 Some(permit)
435 }
436 Ok(Err(_)) => return Err(CrwError::RendererError("host limiter closed".into())),
437 Err(_) => {
438 return Err(CrwError::Timeout(
439 deadline.overrun().as_millis().max(1) as u64
440 ));
441 }
442 }
443 } else {
444 None
445 };
446
447 let effective = resolve_render_js(render_js, self.render_js_default);
448 tracing::debug!(
449 url,
450 request_render_js = ?render_js,
451 default_render_js = ?self.render_js_default,
452 effective_render_js = ?effective,
453 requested_renderer,
454 "FallbackRenderer::fetch dispatching"
455 );
456 let is_hard_pinned = matches!(requested_renderer, Some(name) if name != "auto");
458 match effective {
459 Some(false) => {
460 let mut r = self.http.fetch(url, headers, None, deadline).await?;
461 stamp_http_decision(&mut r, requested_renderer);
462 Ok(r)
463 }
464 Some(true) => {
465 let mut http_result = self.http.fetch(url, headers, None, deadline).await?;
467 if http_result.content_type.as_deref() == Some("application/pdf") {
468 stamp_http_decision(&mut http_result, requested_renderer);
469 return Ok(http_result);
470 }
471
472 if self.js_renderers.is_empty() {
473 tracing::warn!(
474 url,
475 "JS rendering requested but no renderer available — falling back to HTTP"
476 );
477 let mut result = http_result;
478 result.rendered_with = Some("http_only_fallback".to_string());
479 result.warning = Some("JS rendering was requested but no renderer is available. Content was fetched via HTTP only.".to_string());
480 result.warnings.push(
481 "JS rendering requested but no renderer available; HTTP fallback used"
482 .into(),
483 );
484 stamp_http_decision(&mut result, requested_renderer);
485 Ok(result)
486 } else {
487 self.fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
488 .await
489 }
490 }
491 None => {
492 let mut result = match self.http.fetch(url, headers, None, deadline).await {
499 Ok(r) => r,
500 Err(e) if !self.js_renderers.is_empty() => {
501 tracing::info!(
502 url,
503 error = %e,
504 "HTTP fetch failed, escalating to JS renderer"
505 );
506 return self
507 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
508 .await
509 .map_err(|js_err| {
510 tracing::warn!("Both HTTP and JS failed: http={e}, js={js_err}");
511 js_err
512 });
513 }
514 Err(e) => return Err(e),
515 };
516
517 if result.content_type.as_deref() == Some("application/pdf") {
519 stamp_http_decision(&mut result, requested_renderer);
520 return Ok(result);
521 }
522
523 let needs_js = detector::needs_js_rendering(&result.html);
524 let cf_header_signal = result.warning.as_deref() == Some("cloudflare_mitigated");
525 let is_generic_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
526 let is_blocked = cf_header_signal
527 || detector::looks_like_cloudflare_challenge(&result.html)
528 || is_generic_bot_wall;
529 let is_auth_blocked = matches!(
542 result.status_code,
543 401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
544 );
545 let is_2xx = (200..300).contains(&result.status_code);
552 let is_thin_content = is_2xx && detector::looks_like_thin_html(&result.html);
553
554 if !self.js_renderers.is_empty()
555 && (needs_js || is_blocked || is_auth_blocked || is_thin_content)
556 {
557 if is_auth_blocked {
558 tracing::info!(
559 url,
560 status_code = result.status_code,
561 "HTTP {} received, escalating to JS renderer",
562 result.status_code
563 );
564 } else if is_blocked {
565 tracing::info!(
566 url,
567 "Anti-bot challenge detected in HTTP response, escalating to JS renderer"
568 );
569 if is_generic_bot_wall {
570 tracing::info!(
571 url,
572 "Generic anti-bot interstitial detected, escalating to JS renderer"
573 );
574 }
575 } else if needs_js {
576 tracing::info!(url, "SPA shell detected, retrying with JS renderer");
577 } else {
578 tracing::info!(
579 url,
580 html_len = result.html.len(),
581 "HTTP 2xx but body is thin, escalating to JS renderer"
582 );
583 }
584 match self
585 .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
586 .await
587 {
588 Ok(js_result) => Ok(js_result),
589 Err(e) if is_hard_pinned => {
590 Err(e)
593 }
594 Err(e) => {
595 if is_auth_blocked {
603 tracing::error!(
604 url,
605 status_code = result.status_code,
606 "JS escalation failed for soft-block status; surfacing HTTP shell with warning: {e}"
607 );
608 let warning = format!("js_escalation_failed: {e}");
609 result.warning = Some(match result.warning.take() {
610 Some(prev) => format!("{warning}; {prev}"),
611 None => warning,
612 });
613 } else {
614 tracing::warn!(
615 "JS rendering failed, falling back to HTTP result: {e}"
616 );
617 }
618 stamp_http_decision(&mut result, requested_renderer);
619 Ok(result)
620 }
621 }
622 } else {
623 stamp_http_decision(&mut result, requested_renderer);
624 Ok(result)
625 }
626 }
627 }
628 }
629
630 const MIN_RENDERED_TEXT_LEN: usize = 50;
634
635 async fn fetch_with_js(
636 &self,
637 url: &str,
638 headers: &HashMap<String, String>,
639 wait_for_ms: Option<u64>,
640 requested_renderer: Option<&str>,
641 deadline: crw_core::Deadline,
642 ) -> CrwResult<FetchResult> {
643 let host = host_of(url);
644 let is_user_pinned = matches!(requested_renderer, Some(name) if name != "auto");
645 if let Some(pinned) = requested_renderer
646 && let Some(kind) = renderer_kind_for(pinned)
647 {
648 metrics()
649 .user_pin_total
650 .with_label_values(&[kind.as_str()])
651 .inc();
652 }
653
654 let mut renderers: Vec<&Arc<dyn PageFetcher>> = match requested_renderer {
657 Some(name) if name != "auto" => self
658 .js_renderers
659 .iter()
660 .filter(|r| r.name() == name)
661 .collect(),
662 _ => self.js_renderers.iter().collect(),
663 };
664
665 if !is_user_pinned
667 && let Some(RendererKind::Chrome) = self.preferences.preferred(&host).await
668 {
669 renderers.sort_by_key(|r| if r.name() == "chrome" { 0 } else { 1 });
670 tracing::debug!(host = %host, "host promoted to chrome by preference learner");
671 }
672
673 if renderers.is_empty() {
674 let available = self.js_renderer_names();
675 return Err(CrwError::RendererError(format!(
676 "requested renderer '{}' not in pool [{}]",
677 requested_renderer.unwrap_or("auto"),
678 available.join(", ")
679 )));
680 }
681
682 let mut chain: Vec<RendererKind> = Vec::new();
685 let mut breaker_skipped: Vec<RendererKind> = Vec::new();
686 let mut last_error = None;
687 let mut last_failover_reason: Option<FailoverErrorKind> = None;
688 let mut thin_result: Option<FetchResult> = None;
689 let renderers_snapshot: Vec<&Arc<dyn PageFetcher>> = renderers.clone();
694
695 for renderer in renderers {
696 let kind = renderer_kind_for(renderer.name());
697
698 let trackable = kind.filter(|_| !host.is_empty());
701
702 let mut probe_guard: Option<ProbeGuard> = None;
713 if let Some(k) = trackable {
714 let (permit, guard) = self.breakers.acquire_with_guard(&host, k).await;
715 if permit == Permit::Rejected {
716 tracing::info!(
717 renderer = renderer.name(),
718 host = %host,
719 "circuit breaker open, skipping renderer"
720 );
721 metrics()
722 .render_route_decision_total
723 .with_label_values(&[k.as_str(), "breakerSkipped"])
724 .inc();
725 breaker_skipped.push(k);
726 drop(guard); continue;
728 }
729 probe_guard = Some(guard);
730 }
731 if let Some(k) = kind {
732 chain.push(k);
733 }
734
735 let attempt_ctx = {
738 let remaining = deadline.remaining();
739 let tier_budget = kind
740 .and_then(|k| self.tier_timeouts.get(&k).copied())
741 .unwrap_or(remaining);
742 AttemptContext::capture(remaining, tier_budget)
743 };
744 match renderer.fetch(url, headers, wait_for_ms, deadline).await {
745 Ok(mut result) => {
746 let text_len = html_body_text_len(&result.html);
747 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
748 let failed_render = detector::looks_like_failed_render(&result.html);
749 let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
750 if text_len >= Self::MIN_RENDERED_TEXT_LEN
751 && !is_placeholder
752 && failed_render.is_none()
753 && !is_bot_wall
754 {
755 let was_promoted = matches!(
759 self.preferences.preferred(&host).await,
760 Some(RendererKind::Chrome)
761 );
762 if let Some(k) = trackable {
763 let outcome = if result.truncated {
766 BreakerOutcome::Truncated
767 } else {
768 BreakerOutcome::Success
769 };
770 self.breakers.record_outcome(&host, k, outcome).await;
771 self.preferences.record_success(&host).await;
772 metrics()
773 .render_route_decision_total
774 .with_label_values(&[k.as_str(), "success"])
775 .inc();
776 metrics()
777 .host_preferences_size
778 .set(self.preferences.size() as i64);
779 }
780 if let Some(g) = probe_guard.take() {
781 g.disarm();
782 }
783 if let Some(k) = kind {
785 result.credit_cost = credit_for(k);
786 result.render_decision = Some(if is_user_pinned {
787 RenderDecision::UserPinned { renderer: k }
788 } else if !breaker_skipped.is_empty() {
789 RenderDecision::BreakerSkipped {
790 skipped: breaker_skipped[0],
791 chosen: k,
792 }
793 } else if chain.len() > 1 {
794 RenderDecision::Failover {
795 chain: chain.clone(),
796 reason: last_failover_reason
797 .clone()
798 .unwrap_or(FailoverErrorKind::Other),
799 }
800 } else if was_promoted && k == RendererKind::Chrome {
801 RenderDecision::AutoPromoted {
802 chosen: k,
803 from: RendererKind::Lightpanda,
804 reason: "host preference learner".into(),
805 }
806 } else {
807 RenderDecision::AutoDefault { chosen: k }
808 });
809 }
810 return Ok(result);
811 }
812 let err_kind = match failed_render {
815 Some(detector::FailedRenderReason::NextJsClientError) => {
816 FailoverErrorKind::NextJsClientError
817 }
818 Some(detector::FailedRenderReason::ReactMinifiedError) => {
819 FailoverErrorKind::NextJsClientError
820 }
821 Some(detector::FailedRenderReason::EmptyNextRoot) => {
822 FailoverErrorKind::EmptyNextRoot
823 }
824 None if is_placeholder => FailoverErrorKind::PlaceholderContent,
825 None if is_bot_wall => FailoverErrorKind::PlaceholderContent,
826 None => FailoverErrorKind::PlaceholderContent,
827 };
828 last_failover_reason = Some(err_kind.clone());
829 if let Some(k) = trackable {
830 let outcome = classify_outcome(false, false, false, &attempt_ctx);
834 self.breakers.record_outcome(&host, k, outcome).await;
835 if k == RendererKind::Lightpanda
836 && let Some(target) =
837 self.preferences.record_failure(&host, &err_kind).await
838 {
839 metrics()
840 .host_preferences_promotions_total
841 .with_label_values(&[k.as_str(), target.as_str()])
842 .inc();
843 tracing::info!(
844 host = %host,
845 "host promoted by preference learner: {} -> {}",
846 k.as_str(),
847 target.as_str()
848 );
849 }
850 }
851 if let Some(g) = probe_guard.take() {
852 g.disarm();
853 }
854 tracing::info!(
855 renderer = renderer.name(),
856 text_len,
857 is_placeholder,
858 is_bot_wall,
859 failed_render = ?failed_render,
860 "JS renderer returned thin/placeholder/failed content, trying next renderer"
861 );
862 let mut annotated = result;
869 let attempt_warning = if let Some(reason) = failed_render {
870 format!(
871 "{} returned a failed render ({})",
872 renderer.name(),
873 reason.as_str()
874 )
875 } else if is_placeholder {
876 format!("{} returned a loading placeholder", renderer.name())
877 } else if is_bot_wall {
878 format!(
879 "{} returned a generic anti-bot interstitial",
880 renderer.name()
881 )
882 } else {
883 format!(
884 "{} returned thin content (text_len={text_len})",
885 renderer.name()
886 )
887 };
888 if is_bot_wall {
889 last_error = Some(CrwError::RendererError(format!(
898 "{} returned a generic anti-bot interstitial",
899 renderer.name()
900 )));
901 }
902 annotated.warnings.push(attempt_warning.clone());
903 annotated.warning = Some(match annotated.warning {
904 Some(prev) => format!("{prev}; {attempt_warning}"),
905 None => attempt_warning.clone(),
906 });
907 thin_result = Some(match thin_result {
908 None => annotated,
909 Some(existing) => {
910 let (mut keeper, dropped) =
917 if annotated.html.len() > existing.html.len() {
918 (annotated, existing)
919 } else {
920 (existing, annotated)
921 };
922 keeper.warnings.push(attempt_warning.clone());
923 keeper.warning = Some(match keeper.warning {
924 Some(prev) => format!("{prev}; {attempt_warning}"),
925 None => attempt_warning,
926 });
927 for w in dropped.warnings {
930 if !keeper.warnings.contains(&w) {
931 keeper.warnings.push(w);
932 }
933 }
934 keeper
935 }
936 });
937 }
938 Err(e) => {
939 tracing::warn!(renderer = renderer.name(), "JS renderer failed: {e}");
940 let err_kind = classify_renderer_error(&e);
941 last_failover_reason = Some(err_kind.clone());
942 if let Some(k) = trackable {
943 let was_timeout = matches!(e, CrwError::Timeout(_));
944 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
945 self.breakers.record_outcome(&host, k, outcome).await;
946 if k == RendererKind::Lightpanda {
947 let _ = self.preferences.record_failure(&host, &err_kind).await;
948 }
949 }
950 if let Some(g) = probe_guard.take() {
951 g.disarm();
952 }
953 last_error = Some(e);
954 continue;
955 }
956 }
957 }
958 const LEAK_MIN_BUDGET: Duration = Duration::from_millis(500);
976 if thin_result.is_none()
977 && !breaker_skipped.is_empty()
978 && !is_user_pinned
979 && deadline.remaining() >= LEAK_MIN_BUDGET
980 {
981 for renderer in &renderers_snapshot {
982 let kind = renderer_kind_for(renderer.name());
983 let trackable = kind.filter(|_| !host.is_empty());
984 let Some(k) = trackable else { continue };
985 if !breaker_skipped.contains(&k) {
986 continue;
987 }
988 let permit = self.breakers.try_acquire_host_only(&host, k).await;
989 if permit == Permit::Rejected {
990 continue;
991 }
992 tracing::info!(
993 renderer = renderer.name(),
994 host = %host,
995 "global breaker open, host clean — leaking through one attempt"
996 );
997 metrics()
998 .render_route_decision_total
999 .with_label_values(&[k.as_str(), "leakThrough"])
1000 .inc();
1001 let attempt_ctx = {
1002 let remaining = deadline.remaining();
1003 let tier_budget = self.tier_timeouts.get(&k).copied().unwrap_or(remaining);
1004 AttemptContext::capture(remaining, tier_budget)
1005 };
1006 let res = renderer.fetch(url, headers, wait_for_ms, deadline).await;
1007 match res {
1008 Ok(mut result) => {
1009 let text_len = html_body_text_len(&result.html);
1010 let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1011 let failed_render = detector::looks_like_failed_render(&result.html);
1012 let truncated = result.truncated;
1013 let content_ok = text_len >= Self::MIN_RENDERED_TEXT_LEN
1014 && !is_placeholder
1015 && failed_render.is_none();
1016 let outcome = classify_outcome(content_ok, truncated, false, &attempt_ctx);
1017 self.breakers
1020 .record_scoped_outcome(&host, k, None, Some(outcome))
1021 .await;
1022 if content_ok {
1023 result.credit_cost = credit_for(k);
1024 result.render_decision =
1025 Some(RenderDecision::AutoDefault { chosen: k });
1026 return Ok(result);
1027 }
1028 last_error = Some(CrwError::RendererError(format!(
1031 "leak attempt on {} returned thin content (text_len={text_len})",
1032 renderer.name()
1033 )));
1034 break;
1035 }
1036 Err(e) => {
1037 let was_timeout = matches!(e, CrwError::Timeout(_));
1038 let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
1039 self.breakers
1040 .record_scoped_outcome(&host, k, None, Some(outcome))
1041 .await;
1042 last_error = Some(e);
1043 break;
1044 }
1045 }
1046 }
1047 }
1048
1049 if let Some(mut result) = thin_result {
1051 if let Some(last) = chain.last().copied() {
1054 result.credit_cost = credit_for(last);
1055 result.render_decision = Some(RenderDecision::Failover {
1056 chain: chain.clone(),
1057 reason: last_failover_reason
1058 .clone()
1059 .unwrap_or(FailoverErrorKind::Other),
1060 });
1061 }
1062 if is_user_pinned
1067 && chain.len() == 1
1068 && let Some(pinned) = chain.first().copied()
1069 {
1070 let reason = last_failover_reason
1071 .as_ref()
1072 .map(|r| r.as_str())
1073 .unwrap_or("unknown");
1074 let hint = format!(
1075 "Pinned renderer '{}' returned a failed render ({}). Content may be unreliable. Retry with renderer=\"chrome\" or omit the renderer field for auto-failover.",
1076 pinned.as_str(),
1077 reason,
1078 );
1079 result.warnings.push(hint);
1080 }
1081 Ok(result)
1082 } else {
1083 Err(last_error
1084 .unwrap_or_else(|| CrwError::RendererError("No JS renderer available".to_string())))
1085 }
1086 }
1087
1088 pub async fn check_health(&self) -> HashMap<String, bool> {
1090 let mut health = HashMap::new();
1091 health.insert("http".to_string(), self.http.is_available().await);
1092 for r in &self.js_renderers {
1093 health.insert(r.name().to_string(), r.is_available().await);
1094 }
1095 health
1096 }
1097}
1098
1099fn html_body_text_len(html: &str) -> usize {
1103 let body = if let Some(start) = html.find("<body") {
1105 let start = html[start..].find('>').map(|i| start + i + 1).unwrap_or(0);
1106 let end = html.find("</body>").unwrap_or(html.len());
1107 &html[start..end]
1108 } else {
1109 html
1110 };
1111 let mut in_tag = false;
1113 let mut text_len = 0;
1114 let mut prev_ws = true;
1115 for ch in body.chars() {
1116 if ch == '<' {
1117 in_tag = true;
1118 } else if ch == '>' {
1119 in_tag = false;
1120 } else if !in_tag {
1121 if ch.is_whitespace() {
1122 if !prev_ws {
1123 text_len += 1;
1124 prev_ws = true;
1125 }
1126 } else {
1127 text_len += 1;
1128 prev_ws = false;
1129 }
1130 }
1131 }
1132 text_len
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::*;
1138 use crate::breaker::BreakerConfig;
1139 #[cfg(feature = "cdp")]
1140 use crw_core::config::CdpEndpoint;
1141 use std::time::Duration;
1142
1143 fn tdl() -> crw_core::Deadline {
1145 crw_core::Deadline::now_plus(Duration::from_secs(60))
1146 }
1147
1148 fn base_cfg(mode: RendererMode) -> RendererConfig {
1149 RendererConfig {
1150 mode,
1151 ..Default::default()
1152 }
1153 }
1154
1155 #[test]
1156 fn new_mode_none_ok_no_js_renderers() {
1157 let cfg = base_cfg(RendererMode::None);
1158 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1159 assert!(r.js_renderer_names().is_empty());
1160 assert_eq!(r.render_js_default, None);
1161 }
1162
1163 #[test]
1164 fn new_mode_auto_no_endpoints_ok_http_only() {
1165 let cfg = base_cfg(RendererMode::Auto);
1166 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1167 assert!(r.js_renderer_names().is_empty());
1168 }
1169
1170 #[cfg(feature = "cdp")]
1171 #[test]
1172 fn new_mode_chrome_without_endpoint_errors() {
1173 let cfg = base_cfg(RendererMode::Chrome);
1174 let err =
1175 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
1176 let msg = err.to_string().to_lowercase();
1177 assert!(msg.contains("chrome"), "expected chrome in error: {msg}");
1178 assert!(
1179 msg.contains("ws_url") || msg.contains("not configured"),
1180 "expected ws_url hint in error: {msg}"
1181 );
1182 }
1183
1184 #[cfg(feature = "cdp")]
1185 #[test]
1186 fn new_mode_chrome_with_endpoint_ok_only_chrome() {
1187 let cfg = RendererConfig {
1188 mode: RendererMode::Chrome,
1189 chrome: Some(CdpEndpoint {
1190 ws_url: "ws://127.0.0.1:9222/".into(),
1191 }),
1192 lightpanda: Some(CdpEndpoint {
1193 ws_url: "ws://127.0.0.1:9223/".into(),
1194 }),
1195 ..Default::default()
1196 };
1197 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1198 assert_eq!(r.js_renderer_names(), vec!["chrome"]);
1199 }
1200
1201 #[cfg(feature = "cdp")]
1202 #[test]
1203 fn new_mode_lightpanda_without_endpoint_errors() {
1204 let cfg = base_cfg(RendererMode::Lightpanda);
1205 let err =
1206 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
1207 assert!(err.to_string().to_lowercase().contains("lightpanda"));
1208 }
1209
1210 #[cfg(feature = "cdp")]
1211 #[test]
1212 fn new_mode_auto_with_both_endpoints_preserves_order() {
1213 let cfg = RendererConfig {
1214 mode: RendererMode::Auto,
1215 lightpanda: Some(CdpEndpoint {
1216 ws_url: "ws://127.0.0.1:9222/".into(),
1217 }),
1218 chrome: Some(CdpEndpoint {
1219 ws_url: "ws://127.0.0.1:9223/".into(),
1220 }),
1221 ..Default::default()
1222 };
1223 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1224 assert_eq!(r.js_renderer_names(), vec!["lightpanda", "chrome"]);
1225 }
1226
1227 #[cfg(not(feature = "cdp"))]
1228 #[test]
1229 fn new_mode_chrome_errors_without_cdp_feature() {
1230 let cfg = base_cfg(RendererMode::Chrome);
1231 let err =
1232 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
1233 let msg = err.to_string().to_lowercase();
1234 assert!(msg.contains("cdp"), "expected cdp in error: {msg}");
1235 }
1236
1237 #[test]
1238 fn new_render_js_default_stored() {
1239 let cfg = RendererConfig {
1240 mode: RendererMode::None,
1241 render_js_default: Some(true),
1242 ..Default::default()
1243 };
1244 let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1245 assert_eq!(r.render_js_default, Some(true));
1246 }
1247
1248 struct MockFetcher {
1250 name: &'static str,
1251 behavior: MockBehavior,
1252 }
1253
1254 #[derive(Clone)]
1255 enum MockBehavior {
1256 Ok(String),
1257 Err(String),
1258 }
1259
1260 #[async_trait::async_trait]
1261 impl PageFetcher for MockFetcher {
1262 async fn fetch(
1263 &self,
1264 url: &str,
1265 _headers: &HashMap<String, String>,
1266 _wait_for_ms: Option<u64>,
1267 _deadline: crw_core::Deadline,
1268 ) -> CrwResult<FetchResult> {
1269 match &self.behavior {
1270 MockBehavior::Ok(html) => Ok(FetchResult {
1271 url: url.to_string(),
1272 final_url: None,
1273 status_code: 200,
1274 html: html.clone(),
1275 content_type: Some("text/html".to_string()),
1276 raw_bytes: None,
1277 rendered_with: Some(self.name.to_string()),
1278 elapsed_ms: 0,
1279 warning: None,
1280 render_decision: None,
1281 credit_cost: 0,
1282 warnings: Vec::new(),
1283 truncated: false,
1284 deadline_exceeded: false,
1285 captured_responses: Vec::new(),
1286 }),
1287 MockBehavior::Err(msg) => Err(CrwError::RendererError(msg.clone())),
1288 }
1289 }
1290
1291 fn name(&self) -> &str {
1292 self.name
1293 }
1294 fn supports_js(&self) -> bool {
1295 true
1296 }
1297 async fn is_available(&self) -> bool {
1298 true
1299 }
1300 }
1301
1302 fn rich_html(marker: &str) -> String {
1303 format!(
1304 "<html><body><article>{}{}</article></body></html>",
1305 marker,
1306 "x".repeat(200)
1307 )
1308 }
1309
1310 fn make_renderer_with_mocks(mocks: Vec<Arc<dyn PageFetcher>>) -> FallbackRenderer {
1311 let cfg = base_cfg(RendererMode::None);
1313 let mut r =
1314 FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
1315 r.js_renderers = mocks;
1316 r
1317 }
1318
1319 #[tokio::test]
1320 async fn fetch_with_pinned_renderer_filters_pool() {
1321 let lp = Arc::new(MockFetcher {
1322 name: "lightpanda",
1323 behavior: MockBehavior::Ok(rich_html("LP-")),
1324 }) as Arc<dyn PageFetcher>;
1325 let chrome = Arc::new(MockFetcher {
1326 name: "chrome",
1327 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1328 }) as Arc<dyn PageFetcher>;
1329 let r = make_renderer_with_mocks(vec![lp, chrome]);
1330
1331 let result = r
1332 .fetch(
1333 "https://example.com",
1334 &HashMap::new(),
1335 Some(true),
1336 None,
1337 Some("chrome"),
1338 tdl(),
1339 )
1340 .await
1341 .unwrap();
1342 assert!(result.html.contains("CHROME-"), "expected chrome output");
1343 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
1344 }
1345
1346 #[tokio::test]
1347 async fn fetch_with_pinned_renderer_unknown_returns_error() {
1348 let chrome = Arc::new(MockFetcher {
1349 name: "chrome",
1350 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1351 }) as Arc<dyn PageFetcher>;
1352 let r = make_renderer_with_mocks(vec![chrome]);
1353
1354 let err = r
1355 .fetch(
1356 "https://example.com",
1357 &HashMap::new(),
1358 Some(true),
1359 None,
1360 Some("lightpanda"),
1361 tdl(),
1362 )
1363 .await
1364 .unwrap_err();
1365 let msg = err.to_string();
1366 assert!(
1367 msg.contains("lightpanda") && msg.contains("chrome"),
1368 "expected error to name pinned + available: {msg}"
1369 );
1370 }
1371
1372 #[tokio::test]
1373 async fn fetch_with_renderer_auto_uses_full_chain() {
1374 let lp = Arc::new(MockFetcher {
1375 name: "lightpanda",
1376 behavior: MockBehavior::Ok(rich_html("LP-")),
1377 }) as Arc<dyn PageFetcher>;
1378 let chrome = Arc::new(MockFetcher {
1379 name: "chrome",
1380 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1381 }) as Arc<dyn PageFetcher>;
1382 let r = make_renderer_with_mocks(vec![lp, chrome]);
1383
1384 let result = r
1385 .fetch(
1386 "https://example.com",
1387 &HashMap::new(),
1388 Some(true),
1389 None,
1390 Some("auto"),
1391 tdl(),
1392 )
1393 .await
1394 .unwrap();
1395 assert!(result.html.contains("LP-"), "expected lightpanda first");
1397 }
1398
1399 #[tokio::test]
1400 async fn failover_skips_renderer_that_returns_failed_render() {
1401 let bad_lp_html = format!(
1404 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
1405 "x".repeat(200)
1406 );
1407 let lp = Arc::new(MockFetcher {
1408 name: "lightpanda",
1409 behavior: MockBehavior::Ok(bad_lp_html),
1410 }) as Arc<dyn PageFetcher>;
1411 let chrome = Arc::new(MockFetcher {
1412 name: "chrome",
1413 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
1414 }) as Arc<dyn PageFetcher>;
1415 let r = make_renderer_with_mocks(vec![lp, chrome]);
1416
1417 let result = r
1418 .fetch(
1419 "https://example.com",
1420 &HashMap::new(),
1421 Some(true),
1422 None,
1423 None,
1424 tdl(),
1425 )
1426 .await
1427 .unwrap();
1428 assert!(result.html.contains("CHROME-OK"));
1429 assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
1430 }
1431
1432 #[tokio::test]
1433 async fn failover_surfaces_warning_when_only_failed_render_available() {
1434 let bad_lp_html = format!(
1438 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
1439 "x".repeat(200)
1440 );
1441 let lp = Arc::new(MockFetcher {
1442 name: "lightpanda",
1443 behavior: MockBehavior::Ok(bad_lp_html),
1444 }) as Arc<dyn PageFetcher>;
1445 let r = make_renderer_with_mocks(vec![lp]);
1446
1447 let result = r
1448 .fetch(
1449 "https://example.com",
1450 &HashMap::new(),
1451 Some(true),
1452 None,
1453 None,
1454 tdl(),
1455 )
1456 .await
1457 .unwrap();
1458 let warning = result.warning.expect("expected warning to be set");
1459 assert!(
1460 warning.contains("lightpanda") && warning.contains("nextjs_client_error"),
1461 "warning should name renderer + reason: {warning}"
1462 );
1463 }
1464
1465 #[tokio::test]
1466 async fn failover_concats_warnings_across_two_failed_renderers() {
1467 let bad_lp_html = format!(
1471 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
1472 "x".repeat(200)
1473 );
1474 let bad_chrome_html = format!(
1475 "<html><body><div id=\"__next_error__\">{}</div></body></html>",
1476 "y".repeat(200)
1477 );
1478 let lp = Arc::new(MockFetcher {
1479 name: "lightpanda",
1480 behavior: MockBehavior::Ok(bad_lp_html),
1481 }) as Arc<dyn PageFetcher>;
1482 let chrome = Arc::new(MockFetcher {
1483 name: "chrome",
1484 behavior: MockBehavior::Ok(bad_chrome_html),
1485 }) as Arc<dyn PageFetcher>;
1486 let r = make_renderer_with_mocks(vec![lp, chrome]);
1487
1488 let result = r
1489 .fetch(
1490 "https://example.com",
1491 &HashMap::new(),
1492 Some(true),
1493 None,
1494 None,
1495 tdl(),
1496 )
1497 .await
1498 .unwrap();
1499 let warning = result.warning.expect("expected warning to be set");
1500 assert!(
1501 warning.contains("lightpanda") && warning.contains("chrome"),
1502 "warning should mention both renderers: {warning}"
1503 );
1504 }
1505
1506 #[tokio::test]
1507 async fn fetch_pinned_renderer_failure_propagates() {
1508 let chrome = Arc::new(MockFetcher {
1509 name: "chrome",
1510 behavior: MockBehavior::Err("boom".into()),
1511 }) as Arc<dyn PageFetcher>;
1512 let r = make_renderer_with_mocks(vec![chrome]);
1513
1514 let err = r
1515 .fetch(
1516 "https://example.com",
1517 &HashMap::new(),
1518 Some(true),
1519 None,
1520 Some("chrome"),
1521 tdl(),
1522 )
1523 .await
1524 .unwrap_err();
1525 assert!(err.to_string().contains("boom"));
1526 }
1527
1528 #[tokio::test]
1529 async fn auto_promoted_host_tries_chrome_first() {
1530 let lp = Arc::new(MockFetcher {
1534 name: "lightpanda",
1535 behavior: MockBehavior::Ok(rich_html("LP-")),
1536 }) as Arc<dyn PageFetcher>;
1537 let chrome = Arc::new(MockFetcher {
1538 name: "chrome",
1539 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1540 }) as Arc<dyn PageFetcher>;
1541 let r = make_renderer_with_mocks(vec![lp, chrome]);
1542
1543 for _ in 0..3 {
1545 r.preferences
1546 .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
1547 .await;
1548 }
1549
1550 let result = r
1551 .fetch(
1552 "https://example.com",
1553 &HashMap::new(),
1554 Some(true),
1555 None,
1556 None,
1557 tdl(),
1558 )
1559 .await
1560 .unwrap();
1561 assert!(
1562 result.html.contains("CHROME-"),
1563 "promoted host should hit chrome first, got: {}",
1564 &result.html[..80.min(result.html.len())]
1565 );
1566 assert_eq!(result.credit_cost, 2, "chrome costs 2 credits");
1567 assert!(matches!(
1568 result.render_decision,
1569 Some(RenderDecision::AutoPromoted {
1570 chosen: RendererKind::Chrome,
1571 ..
1572 })
1573 ));
1574 }
1575
1576 #[tokio::test]
1577 async fn breaker_skipped_renderer_falls_through_to_next() {
1578 let lp = Arc::new(MockFetcher {
1581 name: "lightpanda",
1582 behavior: MockBehavior::Err("would fire if reached".into()),
1583 }) as Arc<dyn PageFetcher>;
1584 let chrome = Arc::new(MockFetcher {
1585 name: "chrome",
1586 behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
1587 }) as Arc<dyn PageFetcher>;
1588 let mut r = make_renderer_with_mocks(vec![lp, chrome]);
1589
1590 let breaker_cfg = BreakerConfig {
1596 base_cooldown: Duration::from_secs(300),
1597 max_cooldown: Duration::from_secs(300),
1598 ..BreakerConfig::default()
1599 };
1600 r.breakers = Arc::new(BreakerRegistry::new(breaker_cfg));
1601 for _ in 0..80 {
1602 r.breakers
1603 .record_result("example.com", RendererKind::Lightpanda, false)
1604 .await;
1605 }
1606
1607 let result = r
1608 .fetch(
1609 "https://example.com",
1610 &HashMap::new(),
1611 Some(true),
1612 None,
1613 None,
1614 tdl(),
1615 )
1616 .await
1617 .unwrap();
1618 assert!(result.html.contains("CHROME-OK"));
1619 assert!(matches!(
1620 result.render_decision,
1621 Some(RenderDecision::BreakerSkipped {
1622 skipped: RendererKind::Lightpanda,
1623 chosen: RendererKind::Chrome
1624 })
1625 ));
1626 }
1627
1628 #[tokio::test]
1629 async fn user_pinned_failed_render_emits_warning() {
1630 let bad_html = format!(
1635 "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
1636 "x".repeat(200)
1637 );
1638 let lp = Arc::new(MockFetcher {
1639 name: "lightpanda",
1640 behavior: MockBehavior::Ok(bad_html),
1641 }) as Arc<dyn PageFetcher>;
1642 let chrome = Arc::new(MockFetcher {
1643 name: "chrome",
1644 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1645 }) as Arc<dyn PageFetcher>;
1646 let r = make_renderer_with_mocks(vec![lp, chrome]);
1647
1648 let result = r
1649 .fetch(
1650 "https://example.com",
1651 &HashMap::new(),
1652 Some(true),
1653 None,
1654 Some("lightpanda"),
1655 tdl(),
1656 )
1657 .await
1658 .unwrap();
1659 let pin_hint = result
1660 .warnings
1661 .iter()
1662 .find(|w| w.starts_with("Pinned renderer 'lightpanda'"));
1663 assert!(
1664 pin_hint.is_some(),
1665 "expected pin-failure hint in warnings, got: {:?}",
1666 result.warnings
1667 );
1668 let hint = pin_hint.unwrap();
1669 assert!(
1670 hint.contains("nextJsClientError"),
1671 "hint should name camelCase reason: {hint}"
1672 );
1673 assert!(
1674 hint.contains("renderer=\"chrome\""),
1675 "hint should suggest a fix: {hint}"
1676 );
1677 assert!(matches!(
1679 result.render_decision,
1680 Some(RenderDecision::Failover { ref chain, .. }) if chain.len() == 1
1681 ));
1682 }
1683
1684 #[tokio::test]
1685 async fn user_pinned_decision_records_credit_and_kind() {
1686 let chrome = Arc::new(MockFetcher {
1687 name: "chrome",
1688 behavior: MockBehavior::Ok(rich_html("CHROME-")),
1689 }) as Arc<dyn PageFetcher>;
1690 let r = make_renderer_with_mocks(vec![chrome]);
1691 let result = r
1692 .fetch(
1693 "https://example.com",
1694 &HashMap::new(),
1695 Some(true),
1696 None,
1697 Some("chrome"),
1698 tdl(),
1699 )
1700 .await
1701 .unwrap();
1702 assert_eq!(result.credit_cost, 2);
1703 assert!(matches!(
1704 result.render_decision,
1705 Some(RenderDecision::UserPinned {
1706 renderer: RendererKind::Chrome
1707 })
1708 ));
1709 }
1710}