Skip to main content

crw_renderer/
lib.rs

1//! HTTP and headless-browser rendering engine for the CRW web scraper.
2//!
3//! Provides a [`FallbackRenderer`] that fetches pages via plain HTTP and optionally
4//! re-renders them through a CDP-based headless browser when SPA content is detected.
5//!
6//! - [`http_only`] — Simple HTTP fetcher using `reqwest`
7//! - [`detector`] — Heuristic SPA shell detection (empty body, framework markers)
8//! - `cdp` — Chrome DevTools Protocol renderer (LightPanda, Playwright, Chrome) *(requires `cdp` feature)*
9//! - [`traits`] — [`PageFetcher`] trait for pluggable backends
10//!
11//! # Feature flags
12//!
13//! | Flag  | Description |
14//! |-------|-------------|
15//! | `cdp` | Enables CDP WebSocket rendering via `tokio-tungstenite` |
16//!
17//! # Example
18//!
19//! ```rust,no_run
20//! use crw_core::config::RendererConfig;
21//! use crw_renderer::FallbackRenderer;
22//! use std::collections::HashMap;
23//!
24//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
25//! use crw_core::config::StealthConfig;
26//! let config = RendererConfig::default();
27//! let stealth = StealthConfig::default();
28//! let renderer = FallbackRenderer::new(&config, "crw/0.1", None, &stealth)?;
29//! let deadline = crw_core::Deadline::from_request_ms(8000);
30//! let result = renderer.fetch("https://example.com", &HashMap::new(), None, None, None, deadline).await?;
31//! println!("status: {}", result.status_code);
32//! # Ok(())
33//! # }
34//! ```
35
36pub 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
67/// Map a renderer's name string to the closed `RendererKind` enum.
68/// Returns `None` for unknown names (e.g. "playwright" — treated as a
69/// JS renderer but not tracked in metrics/preferences).
70fn 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
79/// Classify a renderer-side error into a `FailoverErrorKind` for the
80/// preference learner. Match on `CrwError` variants (not error strings),
81/// so renaming or rewording the human-readable message can't silently
82/// reclassify failures and over-promote hosts.
83///
84/// Only LightPanda-specific failures drive promotion (see
85/// [`FailoverErrorKind::counts_for_promotion`]); transport / unreachable
86/// errors stay in `NetworkError` so a flaky upstream doesn't push hosts
87/// to Chrome.
88fn 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        // RendererError covers WS disconnects, CDP frame errors, render
94        // pipeline crashes — these are LightPanda-attributable.
95        CrwError::RendererError(_) => FailoverErrorKind::LightpandaCrash,
96        _ => FailoverErrorKind::Other,
97    }
98}
99
100/// Build a per-tier timeout map from the renderer config. Used by the
101/// breaker layer for pre-flight skip and clamp detection.
102fn 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
121/// Per-renderer credit cost. Exposed so the routing layer can populate
122/// `FetchResult.credit_cost` and `/v1/scrape` charge accurately.
123fn credit_for(kind: RendererKind) -> u32 {
124    match kind {
125        RendererKind::Http => 1,
126        RendererKind::Lightpanda => 1,
127        RendererKind::Chrome => 2,
128    }
129}
130
131/// Stamp `render_decision` and `credit_cost` for an HTTP-only result.
132/// `requested_renderer` is taken into account: if the user explicitly
133/// pinned `"http"` we mark it as `UserPinned`, otherwise `AutoDefault`.
134fn 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    // Mirror the JS-renderer metric so dashboards see HTTP routing too.
145    metrics()
146        .render_route_decision_total
147        .with_label_values(&[kind.as_str(), "success"])
148        .inc();
149}
150
151/// Extract the host from a URL string, returning an empty string on failure.
152fn 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
159/// Pick a user-agent: rotate from stealth pool when stealth is enabled.
160fn 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            // Safe: user_agents is non-empty in this branch.
166            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
174/// Composite renderer that tries multiple backends in order.
175pub struct FallbackRenderer {
176    http: Arc<dyn PageFetcher>,
177    js_renderers: Vec<Arc<dyn PageFetcher>>,
178    /// Global default for `render_js` when a request doesn't specify one.
179    render_js_default: Option<bool>,
180    /// Per-host renderer preference learning (auto-mode only).
181    preferences: Arc<HostPreferences>,
182    /// Per-host + global circuit breakers per renderer.
183    breakers: Arc<BreakerRegistry>,
184    /// Per-tier configured timeouts (Duration). Used by the breaker layer
185    /// for pre-flight deadline-skip and clamp detection in
186    /// `AttemptContext::capture`.
187    tier_timeouts: std::collections::HashMap<RendererKind, std::time::Duration>,
188    /// Process-wide per-eTLD+1 rate (req/sec). `0.0` disables the interval
189    /// floor; the concurrency cap below still applies. Configured via
190    /// [`Self::with_host_limits`].
191    requests_per_second: f64,
192    /// Process-wide per-eTLD+1 in-flight cap. `1` enforces strict politeness.
193    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        // A pinned backend (Lightpanda/Chrome/Playwright) must have CDP compiled in
230        // AND its matching endpoint configured. `Auto` and `None` remain functional
231        // without CDP — they just won't spawn any JS renderer.
232        #[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                    // Playwright is treated as a "chrome-equivalent" tier —
291                    // same timeout budget, same kind of work.
292                    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        // Spawn the process-wide CDP telemetry sampler. Idempotent —
334        // OnceLock guarantees a single task across all FallbackRenderer
335        // instances. No-op on the `mode = none` early-return path above.
336        #[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    /// Configure the process-wide per-host limiter (eTLD+1 keyed). Call once
359    /// at startup with values from `CrawlerConfig`. Defaults: rps=0.0 (no
360    /// interval floor), per-host cap=1 (strict politeness).
361    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    /// Access the host preferences cache (for admin endpoints, tests).
372    pub fn preferences(&self) -> Arc<HostPreferences> {
373        Arc::clone(&self.preferences)
374    }
375
376    /// Access the breaker registry (for tests).
377    pub fn breakers(&self) -> Arc<BreakerRegistry> {
378        Arc::clone(&self.breakers)
379    }
380
381    /// Names of the configured JS renderers in fallback order.
382    /// Used for startup logs and tests — does not leak internal types.
383    pub fn js_renderer_names(&self) -> Vec<&str> {
384        self.js_renderers.iter().map(|r| r.name()).collect()
385    }
386
387    /// Fetch a URL with smart mode: HTTP first, then JS if needed.
388    ///
389    /// When `render_js` is `None` (auto-detect), the renderer also escalates to
390    /// JS rendering if the HTTP response looks like an anti-bot challenge page
391    /// (Cloudflare "Just a moment...", etc.). The CDP renderer has built-in
392    /// challenge retry logic that waits for non-interactive JS challenges to
393    /// auto-resolve.
394    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        // Per-eTLD+1 rate-limit + concurrency cap. Held across the entire
404        // fetch (including any escalation to a JS renderer) so a host that
405        // rate-limits HTTP doesn't get hammered by Chrome on retry.
406        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        // A non-"auto" pinned renderer is a hard pin — failures must surface.
457        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                // Fetch via HTTP first to check content type — PDFs can't be JS-rendered.
466                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                // In auto mode, an HTTP-layer failure (TargetUnreachable, body
493                // decode mid-stream, oversize response, transient network) is
494                // not terminal: if a JS renderer is available, escalate. Many
495                // sites that reject reqwest's TLS/UA fingerprint succeed via a
496                // real Chromium navigation. Bench analysis: 10/147 false
497                // "unreachable" + 5/147 "http_502" map to this branch.
498                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                // PDFs don't need JS rendering — return immediately.
518                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                // Soft-block / soft-error status codes where the body often
530                // contains real content despite the status header. Sources:
531                //   - UA/header-based bot filters: 401, 403, 405, 406, 412
532                //   - Rate limits: 429
533                //   - Geo gates: 451
534                //   - Origin overload: 503
535                //   - "Not found" SPAs that 404 the route but render content
536                //     via JS hydration: 404, 410
537                //   - Origin error that still serves a usable page: 500
538                // Firecrawl-comparison (April 2026 bench): the JS render
539                // path recovered content in ~25/99 such cases that HTTP
540                // alone could not.
541                let is_auth_blocked = matches!(
542                    result.status_code,
543                    401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
544                );
545                // Post-fetch thin-content trigger: HTTP returned 2xx but the
546                // body has effectively no extractable text. Catches sites whose
547                // SPA marker we don't recognize (no `id="root"`, no
548                // `__next_data__`) yet still return a near-empty HTML shell.
549                // Bench analysis showed 23/147 failures fall in this bucket
550                // (seattletimes, espn, ionos, huduser, …).
551                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                            // User explicitly pinned a renderer — surface the error
591                            // instead of silently returning the (likely useless) HTTP body.
592                            Err(e)
593                        }
594                        Err(e) => {
595                            // For `is_auth_blocked` (4xx/5xx soft-block status codes), the
596                            // HTTP body is almost certainly an error shell — falling back
597                            // to it silently misleads the caller. Surface the JS failure
598                            // through a warning so the post-extract layer can decide.
599                            // For `needs_js` / `is_blocked` / `is_thin_content`, the HTTP
600                            // body still has *some* useful content so the silent fallback
601                            // remains the safer default.
602                            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    /// Minimum body text length for a JS-rendered result to be considered
631    /// successful. If the rendered page has less visible text than this, the
632    /// next renderer in the chain is tried.
633    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        // Filter the JS pool down to a hard-pinned renderer when one was named.
655        // "auto" or `None` means "use the configured chain".
656        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        // Auto mode: if this host has been promoted, try Chrome first.
666        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        // Track the chain we attempted so we can populate
683        // `RenderDecision::Failover` when nothing succeeded outright.
684        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        // Snapshot for the leak-through fallback below. The main loop
690        // consumes `renderers`; we keep a parallel reference list so a
691        // single skipped renderer can still get a shot when its host
692        // breaker is closed.
693        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            // Skip empty hosts: don't pollute breaker/preference caches
699            // with the "" key when URL parsing failed.
700            let trackable = kind.filter(|_| !host.is_empty());
701
702            // Pre-flight deadline skip removed: classify_outcome in the
703            // breaker layer already ignores DeadlineClamped outcomes, so a
704            // tier-side timeout on near-exhausted budget doesn't poison the
705            // breaker. Letting chrome attempt with partial-DOM budget gives
706            // higher success on legitimately-slow tail URLs than aborting
707            // pre-flight. tier_timeouts is still used by AttemptContext to
708            // detect clamping post-hoc.
709
710            // Consult breaker for tracked renderers. Untracked names (e.g.
711            // "playwright") bypass the breaker for now.
712            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); // not Probe — drop is a no-op
727                    continue;
728                }
729                probe_guard = Some(guard);
730            }
731            if let Some(k) = kind {
732                chain.push(k);
733            }
734
735            // Capture pre-call context so post-await classification is
736            // race-free against deadline drift.
737            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                        // Capture the promotion state BEFORE record_success
756                        // clears the latch — otherwise AutoPromoted decisions
757                        // race against the success path and downgrade to AutoDefault.
758                        let was_promoted = matches!(
759                            self.preferences.preferred(&host).await,
760                            Some(RendererKind::Chrome)
761                        );
762                        if let Some(k) = trackable {
763                            // Treat truncated-but-valid as Truncated (ignored
764                            // by default per BreakerConfig.count_truncated_as_failure).
765                            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                        // Populate routing metadata + per-renderer credit.
784                        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                    // Treat thin/placeholder/failed as a soft failure for
813                    // breaker + preference purposes.
814                    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                        // Thin/placeholder/failed render → classify against
831                        // attempt context so deadline-clamped attempts don't
832                        // poison the breaker.
833                        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                    // Annotate the result so it can surface through `thin_result`
863                    // if no later renderer succeeds. Preserves any warning the
864                    // renderer set, but adds the failover reason. We keep the
865                    // first thin result as the body to return (no point in
866                    // accumulating bodies), but stitch later renderers'
867                    // warnings onto it so debug output reflects every attempt.
868                    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                        // Surface bot-wall as a RendererError so, if every
890                        // renderer in the chain hits a wall, the final error
891                        // (line ~1052) carries an actionable message.
892                        // RendererError maps to FailoverErrorKind::LightpandaCrash
893                        // via classify_renderer_error — that's intentional:
894                        // bot-wall hosts SHOULD be promoted to Chrome by the
895                        // host preference learner, since LightPanda lacks the
896                        // TLS/header fingerprint to clear them.
897                        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                            // Prefer the larger HTML when stitching thin
911                            // results — a later renderer (e.g. chrome) often
912                            // returns a CAPTCHA shell that, while small,
913                            // contains anti-bot markers absent from an even
914                            // smaller earlier shell. Diagnostics & block
915                            // detection then have something to match on.
916                            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                            // Carry over any extra warnings from the dropped
928                            // attempt so debug output stays complete.
929                            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        // Leak-through fallback: every renderer was rejected by the global
959        // breaker, but the host itself has no failures recorded. Rather
960        // than fail the request outright (which is what made the bench
961        // shed ~12% on broad lightpanda outages), give one renderer a
962        // single attempt without recording its outcome to the global
963        // window. The host tier still records, so a host that's actually
964        // broken trips its own breaker on the next attempt.
965        // Trigger when every chain attempt failed outright (no thin_result,
966        // no Ok return) AND at least one renderer was skipped by the global
967        // breaker. Common case: lightpanda runs and errors, chrome gets
968        // globally rejected → without leak we'd return error even though
969        // chrome's host breaker is clean and would likely succeed.
970        //
971        // Skip when the request deadline is already (near-)exhausted:
972        // entering a renderer with <500ms budget produced 37/128 of the
973        // first leak run's failures as "Timeout after 1-2ms" — the
974        // attempt cannot succeed and just consumes a CDP connection.
975        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                        // Record host only — global stays untouched so the
1018                        // existing trip can finish its cooldown naturally.
1019                        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                        // Thin/placeholder on leak path → fall through to
1029                        // the normal "no JS renderer" return below.
1030                        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        // Return the best thin result if we have one, otherwise the last error.
1050        if let Some(mut result) = thin_result {
1051            // Stamp routing metadata on the soft-failure result too — callers
1052            // need to know which chain was attempted for debugging.
1053            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            // When the user hard-pinned a single renderer and it failed thin,
1063            // failover never ran — surface an actionable hint so callers (SaaS
1064            // playground, CLI, MCP) can show a banner instead of silently
1065            // returning broken markdown with `success: true`.
1066            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    /// Check availability of all renderers.
1089    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
1099/// Rough estimate of visible text length in an HTML document.
1100/// Strips tags and collapses whitespace. Used to detect "thin" renders
1101/// where a renderer returned HTML but failed to execute JavaScript.
1102fn html_body_text_len(html: &str) -> usize {
1103    // Extract body content if present, otherwise use entire HTML.
1104    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    // Strip tags crudely.
1112    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    /// Generous deadline used by tests that don't care about budget enforcement.
1144    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    /// Mock fetcher for unit-testing dispatch logic without real CDP/HTTP.
1249    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        // Build a real HTTP fetcher (won't be hit when render_js=Some(true)).
1312        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        // First renderer in the chain wins when both succeed.
1396        assert!(result.html.contains("LP-"), "expected lightpanda first");
1397    }
1398
1399    #[tokio::test]
1400    async fn failover_skips_renderer_that_returns_failed_render() {
1401        // LightPanda returns HTML with a Next.js error boundary marker.
1402        // The chain must skip it and use Chrome's healthy result.
1403        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        // Only LightPanda is configured and it returns a failed render. The
1435        // call must succeed (best-effort thin_result fallback) but the warning
1436        // must name the failure so callers can surface it to the user.
1437        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        // Both renderers return failed-render HTML. The fallback `thin_result`
1468        // should carry warnings from BOTH attempts so debugging captures the
1469        // full chain, not just the first failure.
1470        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        // Pre-promote example.com via the preference learner so the loop
1531        // sorts chrome ahead of lightpanda even though lightpanda was
1532        // declared first. The first renderer in the executed order wins.
1533        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        // Force-promote "example.com" by reaching the failure threshold.
1544        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        // Trip the per-host breaker for lightpanda, then verify the loop
1579        // skips it and uses chrome — without ever calling lightpanda.fetch.
1580        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        // Use a custom breaker config: long cooldown so the breaker can't
1591        // transition to half-open under parallel test load (the default
1592        // 5s cooldown was racing against scheduler latency on workspace runs).
1593        // Threshold/window stay tuned to default: 80 consecutive failures
1594        // satisfies min_calls=50 and far exceeds failure_rate=0.80.
1595        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        // Pin lightpanda. It returns failed-render HTML (Next.js error
1631        // boundary). Because the user hard-pinned, no failover happens.
1632        // The thin result must carry an actionable warning so callers can
1633        // surface it instead of silently returning broken markdown.
1634        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        // chain stays single-element because user pinned → no chrome attempt
1678        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}