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 browser_pool;
42#[cfg(feature = "camoufox")]
43pub mod camoufox;
44#[cfg(feature = "cdp")]
45pub mod cdp;
46#[cfg(feature = "cdp")]
47pub mod cdp_conn;
48pub mod detector;
49#[cfg(feature = "cdp")]
50pub mod health_telemetry;
51pub mod host_limiter;
52pub mod http_only;
53pub mod preference;
54pub mod traits;
55
56use crate::breaker::{
57    AttemptContext, BreakerOutcome, BreakerRegistry, Permit, ProbeGuard, classify_outcome,
58};
59use crate::preference::HostPreferences;
60use crw_core::config::{BUILTIN_UA_POOL, RendererConfig, RendererMode, StealthConfig};
61use crw_core::error::{CrwError, CrwResult};
62use crw_core::metrics::metrics;
63use crw_core::types::{
64    FailoverErrorKind, FetchResult, RenderDecision, RendererKind, resolve_render_js,
65};
66use std::collections::HashMap;
67use std::sync::Arc;
68use std::time::Duration;
69use traits::PageFetcher;
70
71tokio::task_local! {
72    /// Per-request country code (ISO 3166-1 alpha-2, lowercase) for the
73    /// chrome_proxy tier's CDP auth pump. Set by `FallbackRenderer::fetch`
74    /// when a `ScrapeRequest.country` is present; read in `cdp.rs` while
75    /// composing DataImpulse credentials. Task-local so child tasks
76    /// spawned by the pool inherit it without trait-signature churn.
77    pub static REQUEST_COUNTRY: Option<String>;
78}
79
80tokio::task_local! {
81    /// Resolved proxy entry for the current request, picked from the active
82    /// rotator by host. Set by the scrape/crawl entry points (via
83    /// [`FallbackRenderer::pick_proxy`]); read in `cdp.rs` to drive the
84    /// per-request Chrome `proxyServer` (a fresh proxied browser context) and
85    /// the `Fetch.authRequired` pump. `None` = no proxy → existing behaviour.
86    pub static REQUEST_PROXY: Option<Arc<crw_core::ProxyEntry>>;
87}
88
89/// Per-request screenshot capture parameters. Carried via a task-local rather
90/// than the `PageFetcher::fetch` signature (mirrors [`REQUEST_PROXY`]) so the
91/// trait + its ~30 call sites stay untouched. `Some` ⇒ capture a PNG via CDP
92/// `Page.captureScreenshot` after the wait window; `None` ⇒ no screenshot.
93#[derive(Debug, Clone, Copy)]
94pub struct ScreenshotReq {
95    /// Capture the full scrollable page (`captureBeyondViewport`) vs. just the
96    /// current viewport.
97    pub full_page: bool,
98}
99
100tokio::task_local! {
101    /// Resolved screenshot request for the current scrape. Set by the
102    /// scrape/crawl entry point ([`crw_crawl::single::scrape_url`]) when
103    /// `formats` contains `Screenshot`; read in `cdp.rs` to drive
104    /// `Page.captureScreenshot` and in [`FallbackRenderer::fetch`] to force the
105    /// vanilla-Chrome CDP path. `None` = no screenshot → existing behaviour.
106    pub static REQUEST_SCREENSHOT: Option<ScreenshotReq>;
107}
108
109/// Interactive render-slot reserve for a Chrome pool of `pool_size` (the B
110/// reserved lane). About a quarter of the pool, floored at 1 whenever there are
111/// ≥2 slots so even small (2–3 slot) self-hosted pools keep interactive render
112/// isolation; only a 1-slot pool disables it (a reserve there would starve
113/// batch). Always kept below `pool_size` so the batch gate stays ≥1.
114pub fn render_reserve(pool_size: usize) -> usize {
115    if pool_size <= 1 {
116        0
117    } else {
118        (pool_size / 4).max(1).min(pool_size - 1)
119    }
120}
121
122/// Whether a screenshot was requested for the current task (reads the
123/// [`REQUEST_SCREENSHOT`] task-local). `false` when unset / outside a scope.
124pub fn screenshot_requested() -> bool {
125    REQUEST_SCREENSHOT
126        .try_with(|s| s.is_some())
127        .unwrap_or(false)
128}
129
130/// The resolved screenshot params for the current task, if any.
131pub fn current_screenshot_req() -> Option<ScreenshotReq> {
132    REQUEST_SCREENSHOT.try_with(|s| *s).ok().flatten()
133}
134
135/// Map a renderer's name string to the closed `RendererKind` enum.
136/// Returns `None` for unknown names (e.g. "playwright" — treated as a
137/// JS renderer but not tracked in metrics/preferences).
138fn renderer_kind_for(name: &str) -> Option<RendererKind> {
139    match name {
140        "http" | "http_only_fallback" => Some(RendererKind::Http),
141        "lightpanda" => Some(RendererKind::Lightpanda),
142        "chrome" => Some(RendererKind::Chrome),
143        "chrome_proxy" => Some(RendererKind::ChromeProxy),
144        "camoufox" => Some(RendererKind::Camoufox),
145        _ => None,
146    }
147}
148
149/// Classify a renderer-side error into a `FailoverErrorKind` for the
150/// preference learner. Match on `CrwError` variants (not error strings),
151/// so renaming or rewording the human-readable message can't silently
152/// reclassify failures and over-promote hosts.
153///
154/// Only LightPanda-specific failures drive promotion (see
155/// [`FailoverErrorKind::counts_for_promotion`]); transport / unreachable
156/// errors stay in `NetworkError` so a flaky upstream doesn't push hosts
157/// to Chrome.
158fn classify_renderer_error(err: &CrwError) -> FailoverErrorKind {
159    match err {
160        CrwError::Timeout(_) => FailoverErrorKind::LightpandaTimeout,
161        CrwError::TargetUnreachable(_) => FailoverErrorKind::NetworkError,
162        CrwError::HttpError(_) => FailoverErrorKind::NetworkError,
163        // RendererError covers WS disconnects, CDP frame errors, render
164        // pipeline crashes — these are LightPanda-attributable.
165        CrwError::RendererError(_) => FailoverErrorKind::LightpandaCrash,
166        _ => FailoverErrorKind::Other,
167    }
168}
169
170/// Build a per-tier timeout map from the renderer config. Used by the
171/// breaker layer for pre-flight skip and clamp detection.
172fn tier_timeouts_from(
173    config: &RendererConfig,
174) -> std::collections::HashMap<RendererKind, std::time::Duration> {
175    let mut m = std::collections::HashMap::new();
176    m.insert(
177        RendererKind::Http,
178        std::time::Duration::from_millis(config.http_timeout()),
179    );
180    m.insert(
181        RendererKind::Lightpanda,
182        std::time::Duration::from_millis(config.lightpanda_timeout()),
183    );
184    m.insert(
185        RendererKind::Chrome,
186        std::time::Duration::from_millis(config.chrome_timeout()),
187    );
188    m.insert(
189        RendererKind::ChromeProxy,
190        std::time::Duration::from_millis(config.chrome_proxy_timeout()),
191    );
192    // Unconditional: `camoufox_timeout()` exists regardless of feature. The map
193    // entry is consulted only when a camoufox renderer is actually in the pool,
194    // so an unused entry in lean builds is harmless and keeps this function
195    // feature-free.
196    m.insert(
197        RendererKind::Camoufox,
198        std::time::Duration::from_millis(config.camoufox_timeout()),
199    );
200    m
201}
202
203/// Credit cost per fetched page. Flat 1 for every renderer: the SaaS bills 1
204/// credit per scrape regardless of renderer, and `data.credit_cost` is the
205/// field docs tell users to audit their charge against — so it must equal that
206/// charge. ponytail: per-renderer pricing removed; re-add a `match kind` here
207/// (e.g. `Chrome => 2`) if a renderer ever needs to cost more than the base.
208fn credit_for(_kind: RendererKind) -> u32 {
209    1
210}
211
212/// Stamp `render_decision` and `credit_cost` for an HTTP-only result.
213/// `requested_renderer` is taken into account: if the user explicitly
214/// pinned `"http"` we mark it as `UserPinned`, otherwise `AutoDefault`.
215fn stamp_http_decision(result: &mut FetchResult, requested_renderer: Option<&str>) {
216    if result.render_decision.is_some() {
217        return;
218    }
219    let kind = RendererKind::Http;
220    result.credit_cost = credit_for(kind);
221    result.render_decision = Some(match requested_renderer {
222        Some("http") => RenderDecision::UserPinned { renderer: kind },
223        _ => RenderDecision::AutoDefault { chosen: kind },
224    });
225    // Mirror the JS-renderer metric so dashboards see HTTP routing too.
226    metrics()
227        .render_route_decision_total
228        .with_label_values(&[kind.as_str(), "success"])
229        .inc();
230}
231
232/// Extract the host from a URL string, returning an empty string on failure.
233fn host_of(url: &str) -> String {
234    url::Url::parse(url)
235        .ok()
236        .and_then(|u| u.host_str().map(|h| h.to_string()))
237        .unwrap_or_default()
238}
239
240/// Pick a user-agent: rotate from stealth pool when stealth is enabled.
241fn pick_ua<'a>(default_ua: &'a str, stealth: &'a StealthConfig) -> String {
242    if stealth.enabled {
243        let pool: &[&str] = if stealth.user_agents.is_empty() {
244            BUILTIN_UA_POOL
245        } else {
246            // Safe: user_agents is non-empty in this branch.
247            return stealth.user_agents[rand::random_range(0..stealth.user_agents.len())].clone();
248        };
249        pool[rand::random_range(0..pool.len())].to_string()
250    } else {
251        default_ua.to_string()
252    }
253}
254
255/// Pure classification of a JS-renderer result (no side-effects). Produced by
256/// `FallbackRenderer::classify_js_attempt`; consumed by the serial loop and the
257/// conditional hedge to apply the identical accept-gate.
258#[allow(dead_code)] // full classification kept for completeness; hedge uses a subset
259struct JsAttemptClass {
260    text_len: usize,
261    is_placeholder: bool,
262    failed_render: Option<detector::FailedRenderReason>,
263    is_bot_wall: bool,
264    vendor_block: Option<&'static str>,
265    is_status_blocked: bool,
266    antibot: crw_extract::antibot::AntibotResult,
267    antibot_blocked: bool,
268    /// Egress-recoverable hard-block (drives the gated chrome_proxy recovery arm).
269    hard_block: bool,
270    /// Passes the success accept-gate (return as-is, don't escalate).
271    acceptable: bool,
272}
273
274/// Result of the conditional hedge (race lightpanda+chrome).
275enum HedgeOutcome {
276    /// A tier passed the accept-gate — return as-is (terminal).
277    Accepted(FetchResult),
278    /// Both tiers thin/failed — best-thin result + whether a hard-block was seen
279    /// (so the caller can fire the gated auto-egress recovery arm). Mirrors the
280    /// serial loop's `thin_result` + `saw_hard_block` fall-through.
281    Thin(FetchResult, bool),
282}
283
284/// Composite renderer that tries multiple backends in order.
285pub struct FallbackRenderer {
286    http: Arc<dyn PageFetcher>,
287    js_renderers: Vec<Arc<dyn PageFetcher>>,
288    /// Global default for `render_js` when a request doesn't specify one.
289    render_js_default: Option<bool>,
290    /// Phase 0 (latency-qn): emit per-fetch structured timing for bench runs.
291    latency_breakdown: bool,
292    /// Phase 2 (latency-qn): gate chrome_proxy as a hard-block-only recovery arm
293    /// (removed from the normal ladder) instead of an always-on tier.
294    auto_egress_escalation: bool,
295    /// latency-qn: conditional hedge — race lightpanda+chrome concurrently.
296    chrome_hedge: bool,
297    /// Headroom gate for the hedge: bounds concurrent hedges to pool_size/2 so the
298    /// 2-contexts-per-request hedge can never deadlock the context pool. Acquired
299    /// with `try_acquire` (no permit → serial fallback; blocking would defeat the
300    /// latency win).
301    hedge_sem: Arc<tokio::sync::Semaphore>,
302    /// Per-host renderer preference learning (auto-mode only).
303    preferences: Arc<HostPreferences>,
304    /// Per-host + global circuit breakers per renderer.
305    breakers: Arc<BreakerRegistry>,
306    /// Per-tier configured timeouts (Duration). Used by the breaker layer
307    /// for pre-flight deadline-skip and clamp detection in
308    /// `AttemptContext::capture`.
309    tier_timeouts: std::collections::HashMap<RendererKind, std::time::Duration>,
310    /// Process-wide per-eTLD+1 rate (req/sec). `0.0` disables the interval
311    /// floor; the concurrency cap below still applies. Configured via
312    /// [`Self::with_host_limits`].
313    requests_per_second: f64,
314    /// Process-wide per-eTLD+1 in-flight cap for batch/crawl. `1` enforces strict
315    /// politeness. Interactive gets `per_host_interactive_reserve` extra slots.
316    per_host_max_concurrent: u32,
317    /// Extra per-host in-flight slots reserved for interactive traffic (the A
318    /// reserved lane). Total per-host in-flight is bounded by
319    /// `per_host_max_concurrent + this`.
320    per_host_interactive_reserve: u32,
321    /// Anti-bot classifier policy. Drives the in-loop `classify()` call that
322    /// decides whether a 200-status block page is a soft failure (escalate
323    /// toward `chrome_proxy`) or a genuine success.
324    antibot: crw_core::config::AntibotConfig,
325    /// Active proxy rotator. Drives the HTTP fetcher pool and (with the `cdp`
326    /// feature) per-request CDP `proxyServer` selection. `None` = no proxy
327    /// configured → direct connections, byte-identical to prior behavior.
328    proxy_rotator: Option<Arc<crw_core::ProxyRotator>>,
329    /// Saved HTTP-fetcher construction inputs so a per-request proxied client
330    /// can be built on demand (when `REQUEST_PROXY` is set) without re-picking.
331    http_ua: String,
332    http_inject_stealth: bool,
333    http_timeout_ms: u64,
334    /// Warm per-proxy HTTP fetchers keyed by `ProxyEntry::raw()`, so repeated
335    /// requests to the same proxy reuse a connection pool instead of rebuilding
336    /// a client each time. Bounded — cleared past a cap to avoid unbounded
337    /// growth under arbitrary BYOP proxies.
338    proxy_client_cache: std::sync::Mutex<std::collections::HashMap<String, Arc<dyn PageFetcher>>>,
339    /// Chrome browser-context pool handle for graceful drain on shutdown.
340    /// `None` when the pool is disabled or the chrome tier isn't configured.
341    #[cfg(feature = "cdp")]
342    chrome_pool: Option<Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>>,
343    /// Whether the (constructed) camoufox tier participates in the auto ladder
344    /// for this instance's mode. Drives the non-pinned pool filter in
345    /// `fetch_with_js`: when false, a configured camoufox renderer is reachable
346    /// only by an explicit `renderer = "camoufox"` pin, never the auto chain.
347    #[cfg(feature = "camoufox")]
348    camoufox_in_auto: bool,
349}
350
351impl std::fmt::Debug for FallbackRenderer {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        f.debug_struct("FallbackRenderer")
354            .field("http", &self.http.name())
355            .field(
356                "js_renderers",
357                &self
358                    .js_renderers
359                    .iter()
360                    .map(|r| r.name())
361                    .collect::<Vec<_>>(),
362            )
363            .field("render_js_default", &self.render_js_default)
364            .finish()
365    }
366}
367
368impl FallbackRenderer {
369    pub fn new(
370        config: &RendererConfig,
371        user_agent: &str,
372        proxy: Option<&str>,
373        stealth: &StealthConfig,
374    ) -> CrwResult<Self> {
375        let effective_ua = pick_ua(user_agent, stealth);
376        let inject_headers = stealth.enabled && stealth.inject_headers;
377        let http_timeout_ms = config.http_timeout();
378        // Fail closed: a malformed single `proxy` (e.g. CLI `--proxy htp://...`)
379        // is a hard error, never a silent direct connection (real-IP leak).
380        if let Some(p) = proxy {
381            crw_core::ProxyEntry::parse(p).map_err(CrwError::ConfigError)?;
382        }
383        let http = Arc::new(http_only::HttpFetcher::with_timeout(
384            &effective_ua,
385            proxy,
386            inject_headers,
387            std::time::Duration::from_millis(http_timeout_ms),
388        )) as Arc<dyn PageFetcher>;
389
390        // A pinned backend (Lightpanda/Chrome/Playwright) must have CDP compiled in
391        // AND its matching endpoint configured. `Auto` and `None` remain functional
392        // without CDP — they just won't spawn any JS renderer.
393        #[cfg(not(feature = "cdp"))]
394        if matches!(
395            config.mode,
396            RendererMode::Lightpanda | RendererMode::Chrome | RendererMode::Playwright
397        ) {
398            return Err(CrwError::ConfigError(format!(
399                "renderer.mode = {:?} requires the 'cdp' feature, but this build was \
400                 compiled without it. Rebuild with --features cdp or set mode = \"auto\"/\"none\".",
401                config.mode
402            )));
403        }
404
405        // Camoufox is REST, not CDP — it requires the `camoufox` feature
406        // independently of `cdp`. Separate top-level guard (never nested in the
407        // cdp block above) so a camoufox-less build rejects the pin cleanly.
408        #[cfg(not(feature = "camoufox"))]
409        if matches!(config.mode, RendererMode::Camoufox) {
410            return Err(CrwError::ConfigError(
411                "renderer.mode = \"camoufox\" requires the 'camoufox' feature, but this build \
412                 was compiled without it. Rebuild with --features camoufox or set mode = \
413                 \"auto\"/\"none\"."
414                    .into(),
415            ));
416        }
417
418        #[allow(unused_mut)]
419        let mut js_renderers: Vec<Arc<dyn PageFetcher>> = Vec::new();
420
421        if matches!(config.mode, RendererMode::None) {
422            if config.render_js_default == Some(true) {
423                tracing::warn!(
424                    "render_js_default=true has no effect with mode=none; \
425                     requests will fall back to HTTP via http_only_fallback"
426                );
427            }
428            return Ok(Self {
429                http,
430                js_renderers,
431                render_js_default: config.render_js_default,
432                latency_breakdown: config.latency_breakdown,
433                auto_egress_escalation: config.auto_egress_escalation,
434                chrome_hedge: config.chrome_hedge,
435                hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
436                preferences: Arc::new(HostPreferences::with_defaults()),
437                breakers: Arc::new(BreakerRegistry::with_defaults()),
438                tier_timeouts: tier_timeouts_from(config),
439                requests_per_second: 0.0,
440                per_host_max_concurrent: 1,
441                per_host_interactive_reserve: 1,
442                antibot: config.antibot.clone(),
443                proxy_rotator: None,
444                http_ua: effective_ua.clone(),
445                http_inject_stealth: inject_headers,
446                http_timeout_ms,
447                proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
448                #[cfg(feature = "cdp")]
449                chrome_pool: None,
450                // mode=none constructs no renderers, so camoufox is never in
451                // the (empty) ladder.
452                #[cfg(feature = "camoufox")]
453                camoufox_in_auto: false,
454            });
455        }
456
457        #[cfg(feature = "cdp")]
458        let mut chrome_pool: Option<
459            Arc<browser_pool::BrowserContextPool<cdp_conn::CdpConnection>>,
460        > = None;
461
462        #[cfg(feature = "cdp")]
463        {
464            let want = |m: RendererMode| -> bool {
465                matches!(config.mode, RendererMode::Auto) || config.mode == m
466            };
467
468            if want(RendererMode::Lightpanda) {
469                if let Some(lp) = &config.lightpanda {
470                    js_renderers.push(Arc::new(
471                        cdp::CdpRenderer::new(
472                            "lightpanda",
473                            &lp.ws_url,
474                            config.lightpanda_timeout(),
475                            config.pool_size,
476                        )
477                        .with_user_agent(&effective_ua),
478                    ));
479                } else if matches!(config.mode, RendererMode::Lightpanda) {
480                    return Err(CrwError::ConfigError(
481                        "renderer.mode = \"lightpanda\" but [renderer.lightpanda] ws_url is not \
482                         configured"
483                            .into(),
484                    ));
485                }
486            }
487            if want(RendererMode::Playwright) {
488                if let Some(pw) = &config.playwright {
489                    // Playwright is treated as a "chrome-equivalent" tier —
490                    // same timeout budget, same kind of work.
491                    js_renderers.push(Arc::new(
492                        cdp::CdpRenderer::new(
493                            "playwright",
494                            &pw.ws_url,
495                            config.chrome_timeout(),
496                            config.pool_size,
497                        )
498                        .with_user_agent(&effective_ua),
499                    ));
500                } else if matches!(config.mode, RendererMode::Playwright) {
501                    return Err(CrwError::ConfigError(
502                        "renderer.mode = \"playwright\" but [renderer.playwright] ws_url is not \
503                         configured"
504                            .into(),
505                    ));
506                }
507            }
508            if want(RendererMode::Chrome) {
509                if let Some(ch) = &config.chrome {
510                    let blocklist = blocklist::Blocklist::defaults()
511                        .with_stylesheets(config.chrome_intercept_stylesheets);
512                    let mut renderer = cdp::CdpRenderer::new(
513                        "chrome",
514                        &ch.ws_url,
515                        config.chrome_timeout(),
516                        config.pool_size,
517                    )
518                    .with_user_agent(&effective_ua)
519                    .with_nav_budget(config.chrome_nav_budget_ms)
520                    .with_challenge_retries(
521                        config
522                            .chrome_challenge_max_retries
523                            .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
524                    )
525                    .with_spa_selector_max(
526                        config
527                            .chrome_spa_selector_max_ms
528                            .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
529                    )
530                    .with_fast_ready(config.chrome_fast_ready)
531                    .with_interception(
532                        config.chrome_intercept_resources,
533                        blocklist,
534                        config.chrome_host_intercept_disable.clone(),
535                    );
536
537                    // Browser-context pool: gated off on browserless v2 in v1
538                    // per plan §"Out of scope". The backend is set explicitly
539                    // in config; never URL-sniffed.
540                    if config.chrome_context_pool_enabled {
541                        match config.chrome_backend {
542                            crw_core::config::ChromeBackend::Vanilla => {
543                                let pcfg = &config.chrome_pool;
544                                let size = pcfg.size.unwrap_or_else(|| {
545                                    let n = std::thread::available_parallelism()
546                                        .map(|p| p.get())
547                                        .unwrap_or(2);
548                                    std::cmp::max(2, n / 2)
549                                });
550                                renderer = renderer.with_pool(browser_pool::PoolCfg {
551                                    size,
552                                    recycle_after_navs: pcfg.recycle_after_navs,
553                                    idle_timeout: std::time::Duration::from_secs(
554                                        pcfg.idle_timeout_secs,
555                                    ),
556                                    health_check_after: std::time::Duration::from_secs(
557                                        pcfg.health_check_secs,
558                                    ),
559                                    shutdown_drain: std::time::Duration::from_secs(
560                                        pcfg.shutdown_drain_secs,
561                                    ),
562                                    close_target_timeout: std::time::Duration::from_secs(2),
563                                    dispose_ctx_timeout: std::time::Duration::from_secs(1),
564                                    create_ctx_timeout: std::time::Duration::from_secs(1),
565                                });
566                                tracing::info!(
567                                    pool_size = size,
568                                    "chrome browser-context pool enabled"
569                                );
570                            }
571                            crw_core::config::ChromeBackend::Browserless => {
572                                tracing::warn!(
573                                    "chrome_context_pool_enabled = true but \
574                                     chrome_backend = browserless — pool unsupported on \
575                                     this backend in v1, falling back to legacy path"
576                                );
577                            }
578                        }
579                    }
580                    chrome_pool = renderer.pool();
581                    js_renderers.push(Arc::new(renderer));
582                } else if matches!(config.mode, RendererMode::Chrome) {
583                    return Err(CrwError::ConfigError(
584                        "renderer.mode = \"chrome\" but [renderer.chrome] ws_url is not configured"
585                            .into(),
586                    ));
587                }
588                // Residential-proxy Chrome tier: opt-in 4th renderer. Pushed
589                // after `chrome` so the existing in-request fallback loop
590                // (`for renderer in renderers` in fetch_with_js) tries Chrome
591                // direct first and falls through to chrome_proxy on failure.
592                // Skipped when [renderer.chrome_proxy] is unset OR when
593                // `ws_url` is empty (docker-compose passes empty env vars
594                // even when --profile proxy is inactive).
595                if let Some(cp) = config
596                    .chrome_proxy
597                    .as_ref()
598                    .filter(|c| !c.ws_url.trim().is_empty())
599                {
600                    let blocklist = blocklist::Blocklist::defaults()
601                        .with_stylesheets(config.chrome_intercept_stylesheets);
602                    let mut renderer = cdp::CdpRenderer::new(
603                        "chrome_proxy",
604                        &cp.ws_url,
605                        config.chrome_proxy_timeout(),
606                        config.pool_size,
607                    )
608                    .with_user_agent(&effective_ua)
609                    .with_nav_budget(config.chrome_nav_budget_ms)
610                    .with_challenge_retries(
611                        config
612                            .chrome_challenge_max_retries
613                            .unwrap_or(cdp::CHALLENGE_MAX_RETRIES),
614                    )
615                    .with_spa_selector_max(
616                        config
617                            .chrome_spa_selector_max_ms
618                            .unwrap_or(cdp::SPA_SELECTOR_MAX_MS),
619                    )
620                    .with_fast_ready(config.chrome_fast_ready)
621                    .with_interception(
622                        config.chrome_intercept_resources,
623                        blocklist,
624                        config.chrome_host_intercept_disable.clone(),
625                    );
626                    // Wire DataImpulse base creds when configured. The renderer
627                    // composes `{base_user}__cr.{country}` per request and replies
628                    // to Chrome's `Fetch.authRequired` via CDP — replacing the
629                    // removed gost forwarder.
630                    if let (Some(u), Some(p)) = (&config.proxy_base_user, &config.proxy_base_pass) {
631                        renderer = renderer.with_proxy_auth_base(
632                            u.clone(),
633                            p.clone(),
634                            config.proxy_default_country.clone(),
635                        );
636                    }
637                    tracing::info!(
638                        ws_url = %cp.ws_url,
639                        proxy_auth = config.proxy_base_user.is_some(),
640                        default_country = ?config.proxy_default_country,
641                        "chrome_proxy tier enabled"
642                    );
643                    js_renderers.push(Arc::new(renderer));
644                }
645            }
646        }
647
648        // Camoufox REST tier — a TOP-LEVEL block, NOT nested in the cdp guard
649        // above (camoufox is REST, not CDP). The renderer is constructed
650        // whenever an endpoint is configured, so an explicit per-request
651        // `renderer = "camoufox"` pin can always reach it. Whether it
652        // participates in the *auto* (non-pinned) chain is decided at request
653        // time in `fetch_with_js` via `camoufox_in_auto` — a configured
654        // endpoint with `include_in_auto = false` stays out of the auto ladder.
655        #[cfg(feature = "camoufox")]
656        {
657            if let Some(cf) = config
658                .camoufox
659                .as_ref()
660                .filter(|c| !c.base_url.trim().is_empty())
661            {
662                js_renderers.push(Arc::new(camoufox::CamoufoxRenderer::new(
663                    "camoufox",
664                    &cf.base_url,
665                    &cf.api_key,
666                    config.camoufox_timeout(),
667                )) as Arc<dyn PageFetcher>);
668                tracing::info!(
669                    base_url = %cf.base_url,
670                    include_in_auto = cf.include_in_auto,
671                    "camoufox tier enabled"
672                );
673            } else if matches!(config.mode, RendererMode::Camoufox) {
674                return Err(CrwError::ConfigError(
675                    "renderer.mode = \"camoufox\" but [renderer.camoufox] base_url is not configured"
676                        .into(),
677                ));
678            }
679        }
680
681        // Spawn the process-wide CDP telemetry sampler. Idempotent —
682        // OnceLock guarantees a single task across all FallbackRenderer
683        // instances. No-op on the `mode = none` early-return path above.
684        #[cfg(feature = "cdp")]
685        health_telemetry::spawn_once();
686
687        if config.render_js_default == Some(true) && js_renderers.is_empty() {
688            tracing::warn!(
689                "render_js_default=true but no JS renderer is available; \
690                 requests will fall back to HTTP via http_only_fallback"
691            );
692        }
693
694        Ok(Self {
695            http,
696            js_renderers,
697            render_js_default: config.render_js_default,
698            latency_breakdown: config.latency_breakdown,
699            auto_egress_escalation: config.auto_egress_escalation,
700            chrome_hedge: config.chrome_hedge,
701            hedge_sem: Arc::new(tokio::sync::Semaphore::new((config.pool_size / 2).max(1))),
702            preferences: Arc::new(HostPreferences::with_defaults()),
703            breakers: Arc::new(BreakerRegistry::with_defaults()),
704            tier_timeouts: tier_timeouts_from(config),
705            requests_per_second: 0.0,
706            per_host_max_concurrent: 1,
707            per_host_interactive_reserve: 1,
708            antibot: config.antibot.clone(),
709            proxy_rotator: None,
710            http_ua: effective_ua.clone(),
711            http_inject_stealth: inject_headers,
712            http_timeout_ms,
713            proxy_client_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
714            #[cfg(feature = "cdp")]
715            chrome_pool,
716            // Single source of truth for the opt-in policy: true only when
717            // mode=camoufox (pinned) or mode=auto + include_in_auto. A
718            // configured-but-not-opted-in endpoint stays out of the auto chain.
719            #[cfg(feature = "camoufox")]
720            camoufox_in_auto: config.camoufox_in_ladder(),
721        })
722    }
723
724    /// Attach the config proxy rotator. Retained so scrape/crawl entry points
725    /// can resolve a per-request proxy (via [`Self::pick_proxy_for_url`]) into
726    /// the [`REQUEST_PROXY`] task-local; the HTTP and CDP paths then both consume
727    /// that single resolved entry — no second pick. `None` is a no-op. Builder
728    /// style so `new()`'s signature stays stable.
729    pub fn with_proxy_rotator(
730        mut self,
731        rotator: Option<Arc<crw_core::ProxyRotator>>,
732    ) -> CrwResult<Self> {
733        self.proxy_rotator = rotator;
734        Ok(self)
735    }
736
737    /// The HTTP fetcher to use for the current request. When `REQUEST_PROXY` is
738    /// set (resolved once by the caller, honoring BYOP > config precedence),
739    /// build a client bound to THAT exact proxy so the HTTP path egresses
740    /// through the same proxy the CDP path uses. Hard-fails on a bad proxy
741    /// (never a silent direct connection). When unset, use the shared
742    /// (no-proxy or single-proxy) fetcher from `new()`.
743    fn http_fetcher_for_request(&self) -> CrwResult<Arc<dyn PageFetcher>> {
744        let Some(entry) = REQUEST_PROXY.try_with(|p| p.clone()).ok().flatten() else {
745            return Ok(self.http.clone());
746        };
747        // Reuse a warm per-proxy client if we've built one before.
748        if let Some(f) = self
749            .proxy_client_cache
750            .lock()
751            .unwrap_or_else(|e| e.into_inner())
752            .get(entry.raw())
753            .cloned()
754        {
755            return Ok(f);
756        }
757        let fetcher: Arc<dyn PageFetcher> = Arc::new(http_only::HttpFetcher::with_proxy(
758            &self.http_ua,
759            entry.raw(),
760            self.http_inject_stealth,
761            std::time::Duration::from_millis(self.http_timeout_ms),
762        )?);
763        let mut cache = self
764            .proxy_client_cache
765            .lock()
766            .unwrap_or_else(|e| e.into_inner());
767        // Bound growth under arbitrary BYOP proxies (config pools are small).
768        if cache.len() >= 512 {
769            cache.clear();
770        }
771        cache.insert(entry.raw().to_string(), fetcher.clone());
772        Ok(fetcher)
773    }
774
775    /// Pick a proxy from the configured rotator for `host` (honoring the
776    /// rotation strategy). `None` when no proxy is configured. Scrape/crawl
777    /// entry points call this and scope the result into the [`REQUEST_PROXY`]
778    /// task-local so the CDP/JS path egresses through the chosen proxy.
779    pub fn pick_proxy(&self, host: Option<&str>) -> Option<Arc<crw_core::ProxyEntry>> {
780        self.proxy_rotator
781            .as_ref()
782            .map(|r| Arc::new(r.pick(host).clone()))
783    }
784
785    /// True when a JS renderer (chrome / lightpanda / chrome_proxy) is wired in,
786    /// so a `render_js` request can actually execute a page. The sitemap
787    /// escalation arm uses this to skip pointless re-fetches of a challenged
788    /// sitemap when no renderer could clear the wall anyway.
789    pub fn js_capable(&self) -> bool {
790        !self.js_renderers.is_empty() || self.auto_egress_escalation
791    }
792
793    /// Like [`Self::pick_proxy`] but derives the host key from a URL using the
794    /// same normalization the HTTP fetcher and host limiter use — so the CDP
795    /// `proxyServer` and the HTTP client land on the SAME sticky proxy.
796    pub fn pick_proxy_for_url(&self, url: &str) -> Option<Arc<crw_core::ProxyEntry>> {
797        self.proxy_rotator.as_ref()?;
798        let host = url::Url::parse(url)
799            .ok()
800            .and_then(|u| u.host_str().map(crate::preference::normalize_host));
801        self.pick_proxy(host.as_deref())
802    }
803
804    /// Drain the chrome browser-context pool. Idempotent and a no-op when
805    /// the pool is disabled. Call from the server's SIGTERM handler after
806    /// the HTTP server has finished serving in-flight requests.
807    #[cfg(feature = "cdp")]
808    pub async fn shutdown_chrome_pool(&self, drain: std::time::Duration) {
809        if let Some(pool) = self.chrome_pool.clone() {
810            tracing::info!(
811                drain_secs = drain.as_secs(),
812                "draining chrome browser-context pool"
813            );
814            pool.shutdown(drain).await;
815        }
816    }
817
818    /// No-op when the `cdp` feature is disabled — keeps caller code simple.
819    #[cfg(not(feature = "cdp"))]
820    pub async fn shutdown_chrome_pool(&self, _drain: std::time::Duration) {}
821
822    /// Configure the process-wide per-host limiter (eTLD+1 keyed). Call once
823    /// at startup with values from `CrawlerConfig`. Defaults: rps=0.0 (no
824    /// interval floor), per-host cap=1 (strict politeness).
825    pub fn with_host_limits(
826        mut self,
827        requests_per_second: f64,
828        per_host_max_concurrent: u32,
829        per_host_interactive_reserve: u32,
830    ) -> Self {
831        self.requests_per_second = requests_per_second;
832        self.per_host_max_concurrent = per_host_max_concurrent;
833        self.per_host_interactive_reserve = per_host_interactive_reserve;
834        self
835    }
836
837    /// Access the host preferences cache (for admin endpoints, tests).
838    pub fn preferences(&self) -> Arc<HostPreferences> {
839        Arc::clone(&self.preferences)
840    }
841
842    /// Access the breaker registry (for tests).
843    pub fn breakers(&self) -> Arc<BreakerRegistry> {
844        Arc::clone(&self.breakers)
845    }
846
847    /// Names of the configured JS renderers in fallback order.
848    /// Used for startup logs and tests — does not leak internal types.
849    pub fn js_renderer_names(&self) -> Vec<&str> {
850        self.js_renderers.iter().map(|r| r.name()).collect()
851    }
852
853    /// Fetch a URL with smart mode: HTTP first, then JS if needed.
854    ///
855    /// When `render_js` is `None` (auto-detect), the renderer also escalates to
856    /// JS rendering if the HTTP response looks like an anti-bot challenge page
857    /// (Cloudflare "Just a moment...", etc.). The CDP renderer has built-in
858    /// challenge retry logic that waits for non-interactive JS challenges to
859    /// auto-resolve.
860    pub async fn fetch(
861        &self,
862        url: &str,
863        headers: &HashMap<String, String>,
864        render_js: Option<bool>,
865        wait_for_ms: Option<u64>,
866        requested_renderer: Option<&str>,
867        deadline: crw_core::Deadline,
868    ) -> CrwResult<FetchResult> {
869        // Phase 0 (latency-qn): time the whole fetch and emit a structured
870        // breakdown event so bench runs can attribute p90 to a tier. The flag
871        // is off by default, so the only cost on the hot path is one cheap
872        // `Instant::now()` + a branch. The accepted tier is `rendered_with`,
873        // which already distinguishes the HTTP fast-path from each JS renderer.
874        if !self.latency_breakdown {
875            return self
876                .fetch_inner(
877                    url,
878                    headers,
879                    render_js,
880                    wait_for_ms,
881                    requested_renderer,
882                    deadline,
883                )
884                .await;
885        }
886        let t0 = std::time::Instant::now();
887        let out = self
888            .fetch_inner(
889                url,
890                headers,
891                render_js,
892                wait_for_ms,
893                requested_renderer,
894                deadline,
895            )
896            .await;
897        let total_ms = t0.elapsed().as_millis() as u64;
898        match &out {
899            Ok(r) => tracing::info!(
900                target: "latency_breakdown",
901                url,
902                total_ms,
903                rendered_with = r.rendered_with.as_deref().unwrap_or("unknown"),
904                content_len = r.html.len(),
905                "scrape latency breakdown"
906            ),
907            Err(e) => tracing::info!(
908                target: "latency_breakdown",
909                url,
910                total_ms,
911                error = %e,
912                "scrape latency breakdown (error)"
913            ),
914        }
915        out
916    }
917
918    async fn fetch_inner(
919        &self,
920        url: &str,
921        headers: &HashMap<String, String>,
922        render_js: Option<bool>,
923        wait_for_ms: Option<u64>,
924        requested_renderer: Option<&str>,
925        deadline: crw_core::Deadline,
926    ) -> CrwResult<FetchResult> {
927        // Per-eTLD+1 rate-limit + concurrency cap. Held across the entire
928        // fetch (including any escalation to a JS renderer) so a host that
929        // rate-limits HTTP doesn't get hammered by Chrome on retry.
930        let host_key = url::Url::parse(url)
931            .ok()
932            .and_then(|u| u.host_str().map(crate::preference::normalize_host));
933        let _host_permit = if let Some(key) = host_key.as_deref() {
934            let remaining = deadline.remaining();
935            if remaining.is_zero() {
936                return Err(CrwError::Timeout(
937                    deadline.overrun().as_millis().max(1) as u64
938                ));
939            }
940            match tokio::time::timeout(
941                remaining,
942                crate::host_limiter::acquire(
943                    key,
944                    self.requests_per_second,
945                    self.per_host_max_concurrent as usize,
946                    self.per_host_interactive_reserve as usize,
947                ),
948            )
949            .await
950            {
951                Ok((permit, sleep)) => {
952                    if !sleep.is_zero() {
953                        let budget = deadline.remaining();
954                        if sleep > budget {
955                            return Err(CrwError::Timeout(sleep.as_millis().max(1) as u64));
956                        }
957                        tokio::time::sleep(sleep).await;
958                    }
959                    // Reserved per-host lane permit (interactive gets a dedicated
960                    // slot, batch a bounded one). Held for the whole fetch by
961                    // binding it to `_host_permit`.
962                    Some(permit)
963                }
964                Err(_) => {
965                    return Err(CrwError::Timeout(
966                        deadline.overrun().as_millis().max(1) as u64
967                    ));
968                }
969            }
970        } else {
971            None
972        };
973
974        let mut effective = resolve_render_js(render_js, self.render_js_default);
975        // A screenshot is captured via CDP — it can only happen on the JS/CDP
976        // path. Force `render_js = Some(true)` so the `Some(false)` / auto
977        // (`None`) branches below don't return an HTTP-only result that never
978        // reaches `fetch_with_js` (where the capture occurs). The HTTP-only,
979        // camoufox and lightpanda renderers are also filtered out downstream.
980        if effective != Some(true) && screenshot_requested() {
981            effective = Some(true);
982        }
983        tracing::debug!(
984            url,
985            request_render_js = ?render_js,
986            default_render_js = ?self.render_js_default,
987            effective_render_js = ?effective,
988            requested_renderer,
989            "FallbackRenderer::fetch dispatching"
990        );
991        // A non-"auto" pinned renderer is a hard pin — failures must surface.
992        let is_hard_pinned = matches!(requested_renderer, Some(name) if name != "auto");
993        match effective {
994            Some(false) => {
995                let mut r = self
996                    .http_fetcher_for_request()?
997                    .fetch(url, headers, None, deadline)
998                    .await?;
999                stamp_http_decision(&mut r, requested_renderer);
1000                Ok(r)
1001            }
1002            Some(true) => {
1003                // Fetch via HTTP first to check content type — PDFs can't be JS-rendered.
1004                let mut http_result = self
1005                    .http_fetcher_for_request()?
1006                    .fetch(url, headers, None, deadline)
1007                    .await?;
1008                if http_result.content_type.as_deref() == Some("application/pdf") {
1009                    // A PDF has no rendered DOM to capture. A screenshot request
1010                    // on a PDF returns the parsed document with no `screenshot`
1011                    // field (ponytail: honest null — PDFs genuinely can't be
1012                    // screenshotted; not worth a warning the PDF parse path drops).
1013                    stamp_http_decision(&mut http_result, requested_renderer);
1014                    return Ok(http_result);
1015                }
1016
1017                if self.js_renderers.is_empty() {
1018                    // A screenshot needs CDP — there is no HTTP fallback that can
1019                    // satisfy it. Fail closed rather than return a 200 with a null
1020                    // screenshot the caller explicitly asked for.
1021                    if screenshot_requested() {
1022                        return Err(CrwError::RendererError(
1023                            "a screenshot was requested but no JS renderer is available; \
1024                             configure a chrome/chrome_proxy tier"
1025                                .into(),
1026                        ));
1027                    }
1028                    tracing::warn!(
1029                        url,
1030                        "JS rendering requested but no renderer available — falling back to HTTP"
1031                    );
1032                    let mut result = http_result;
1033                    result.rendered_with = Some("http_only_fallback".to_string());
1034                    result.warning = Some("JS rendering was requested but no renderer is available. Content was fetched via HTTP only.".to_string());
1035                    result.warnings.push(
1036                        "JS rendering requested but no renderer available; HTTP fallback used"
1037                            .into(),
1038                    );
1039                    stamp_http_decision(&mut result, requested_renderer);
1040                    Ok(result)
1041                } else {
1042                    self.fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1043                        .await
1044                }
1045            }
1046            None => {
1047                // In auto mode, an HTTP-layer failure (TargetUnreachable, body
1048                // decode mid-stream, oversize response, transient network) is
1049                // not terminal: if a JS renderer is available, escalate. Many
1050                // sites that reject reqwest's TLS/UA fingerprint succeed via a
1051                // real Chromium navigation. Bench analysis: 10/147 false
1052                // "unreachable" + 5/147 "http_502" map to this branch.
1053                let mut result = match self
1054                    .http_fetcher_for_request()?
1055                    .fetch(url, headers, None, deadline)
1056                    .await
1057                {
1058                    Ok(r) => r,
1059                    Err(e) if !self.js_renderers.is_empty() => {
1060                        tracing::info!(
1061                            url,
1062                            error = %e,
1063                            "HTTP fetch failed, escalating to JS renderer"
1064                        );
1065                        return self
1066                            .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1067                            .await
1068                            .map_err(|js_err| {
1069                                tracing::warn!("Both HTTP and JS failed: http={e}, js={js_err}");
1070                                js_err
1071                            });
1072                    }
1073                    Err(e) => return Err(e),
1074                };
1075
1076                // PDFs don't need JS rendering — return immediately.
1077                if result.content_type.as_deref() == Some("application/pdf") {
1078                    stamp_http_decision(&mut result, requested_renderer);
1079                    return Ok(result);
1080                }
1081
1082                let needs_js = detector::needs_js_rendering(&result.html);
1083                let cf_header_signal = result.warning.as_deref() == Some("cloudflare_mitigated");
1084                let is_generic_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1085                let is_blocked = cf_header_signal
1086                    || detector::looks_like_cloudflare_challenge(&result.html)
1087                    || is_generic_bot_wall;
1088                // Soft-block / soft-error status codes where the body often
1089                // contains real content despite the status header. Sources:
1090                //   - UA/header-based bot filters: 401, 403, 405, 406, 412
1091                //   - Rate limits: 429
1092                //   - Geo gates: 451
1093                //   - Origin overload: 503
1094                //   - "Not found" SPAs that 404 the route but render content
1095                //     via JS hydration: 404, 410
1096                //   - Origin error that still serves a usable page: 500
1097                // Firecrawl-comparison (April 2026 bench): the JS render
1098                // path recovered content in ~25/99 such cases that HTTP
1099                // alone could not.
1100                let is_auth_blocked = matches!(
1101                    result.status_code,
1102                    401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1103                );
1104                // Post-fetch thin-content trigger: HTTP returned 2xx but the
1105                // body has effectively no extractable text. Catches sites whose
1106                // SPA marker we don't recognize (no `id="root"`, no
1107                // `__next_data__`) yet still return a near-empty HTML shell.
1108                // Bench analysis showed 23/147 failures fall in this bucket
1109                // (seattletimes, espn, ionos, huduser, …).
1110                // Escalate a thin 2xx body ONLY when a browser would plausibly
1111                // reveal more (executable JS, or a meta-refresh redirect). A
1112                // script-less static doc (e.g. example.com) is already complete,
1113                // so a headless render just adds seconds for nothing. The
1114                // recognized-shell sites this bucket targets (seattletimes, espn,
1115                // …) all ship script bundles, so they still escalate.
1116                let is_2xx = (200..300).contains(&result.status_code);
1117                let is_thin_content = is_2xx
1118                    && detector::looks_like_thin_html(&result.html)
1119                    && detector::warrants_browser_retry(&result.html);
1120
1121                if !self.js_renderers.is_empty()
1122                    && (needs_js || is_blocked || is_auth_blocked || is_thin_content)
1123                {
1124                    if is_auth_blocked {
1125                        tracing::info!(
1126                            url,
1127                            status_code = result.status_code,
1128                            "HTTP {} received, escalating to JS renderer",
1129                            result.status_code
1130                        );
1131                    } else if is_blocked {
1132                        tracing::info!(
1133                            url,
1134                            "Anti-bot challenge detected in HTTP response, escalating to JS renderer"
1135                        );
1136                        if is_generic_bot_wall {
1137                            tracing::info!(
1138                                url,
1139                                "Generic anti-bot interstitial detected, escalating to JS renderer"
1140                            );
1141                        }
1142                    } else if needs_js {
1143                        tracing::info!(url, "SPA shell detected, retrying with JS renderer");
1144                    } else {
1145                        tracing::info!(
1146                            url,
1147                            html_len = result.html.len(),
1148                            "HTTP 2xx but body is thin, escalating to JS renderer"
1149                        );
1150                    }
1151                    match self
1152                        .fetch_with_js(url, headers, wait_for_ms, requested_renderer, deadline)
1153                        .await
1154                    {
1155                        Ok(js_result) => Ok(js_result),
1156                        Err(e) if is_hard_pinned => {
1157                            // User explicitly pinned a renderer — surface the error
1158                            // instead of silently returning the (likely useless) HTTP body.
1159                            Err(e)
1160                        }
1161                        Err(e) => {
1162                            // For `is_auth_blocked` (4xx/5xx soft-block status codes), the
1163                            // HTTP body is almost certainly an error shell — falling back
1164                            // to it silently misleads the caller. Surface the JS failure
1165                            // through a warning so the post-extract layer can decide.
1166                            // For `needs_js` / `is_blocked` / `is_thin_content`, the HTTP
1167                            // body still has *some* useful content so the silent fallback
1168                            // remains the safer default.
1169                            if is_auth_blocked {
1170                                tracing::error!(
1171                                    url,
1172                                    status_code = result.status_code,
1173                                    "JS escalation failed for soft-block status; surfacing HTTP shell with warning: {e}"
1174                                );
1175                                let warning = format!("js_escalation_failed: {e}");
1176                                result.warning = Some(match result.warning.take() {
1177                                    Some(prev) => format!("{warning}; {prev}"),
1178                                    None => warning,
1179                                });
1180                            } else {
1181                                tracing::warn!(
1182                                    "JS rendering failed, falling back to HTTP result: {e}"
1183                                );
1184                            }
1185                            stamp_http_decision(&mut result, requested_renderer);
1186                            Ok(result)
1187                        }
1188                    }
1189                } else {
1190                    stamp_http_decision(&mut result, requested_renderer);
1191                    Ok(result)
1192                }
1193            }
1194        }
1195    }
1196
1197    /// Minimum body text length for a JS-rendered result to be considered
1198    /// successful. If the rendered page has less visible text than this, the
1199    /// next renderer in the chain is tried.
1200    const MIN_RENDERED_TEXT_LEN: usize = 50;
1201
1202    /// Pure classification of a JS-renderer result: the accept-gate + thin/block
1203    /// signals, NO side-effects. Shared by the serial escalation loop and the
1204    /// conditional hedge so both apply the identical accept criteria (the red
1205    /// line: hedge must be provably ≡ serial on success/recall).
1206    fn classify_js_attempt(&self, result: &FetchResult) -> JsAttemptClass {
1207        let text_len = html_body_text_len(&result.html);
1208        let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1209        let failed_render = detector::looks_like_failed_render(&result.html);
1210        let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1211        let vendor_block = detector::looks_like_vendor_block(&result.html);
1212        let is_status_blocked = matches!(
1213            result.status_code,
1214            401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1215        );
1216        let antibot = if self.antibot.enabled {
1217            crw_extract::antibot::classify(Some(result.status_code), &result.html)
1218        } else {
1219            crw_extract::antibot::AntibotResult::none()
1220        };
1221        let antibot_blocked = self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1222        // Egress-recoverable hard-block subset (drives the gated chrome_proxy arm).
1223        let hard_block = matches!(result.status_code, 401 | 403 | 429 | 503)
1224            || (520..=530).contains(&result.status_code)
1225            || is_bot_wall
1226            || vendor_block.is_some()
1227            || antibot.signal.is_blocked();
1228        let acceptable = text_len >= Self::MIN_RENDERED_TEXT_LEN
1229            && !is_placeholder
1230            && failed_render.is_none()
1231            && !is_bot_wall
1232            && vendor_block.is_none()
1233            && !is_status_blocked
1234            && !antibot_blocked;
1235        JsAttemptClass {
1236            text_len,
1237            is_placeholder,
1238            failed_render,
1239            is_bot_wall,
1240            vendor_block,
1241            is_status_blocked,
1242            antibot,
1243            antibot_blocked,
1244            hard_block,
1245            acceptable,
1246        }
1247    }
1248
1249    /// Conditional hedge: race lightpanda + chrome CONCURRENTLY (chrome's render
1250    /// clock starts immediately instead of after lightpanda fails) and take the
1251    /// best result by tier priority. Returns `None` if a breaker was open (caller
1252    /// falls back to serial). Success/recall ≡ serial:
1253    ///   * Rule A: among gate-passing results, lightpanda wins (serial accepts
1254    ///     lightpanda when it passes, never seeing chrome); a faster-arriving
1255    ///     chrome only wins if lightpanda is NOT acceptable.
1256    ///   * Rule B: if neither passes, return the richest-HTML thin (== serial's
1257    ///     thin stitch).
1258    ///   * Rule C: record breaker/preference side-effects only for tiers that
1259    ///     actually COMPLETED (the cancelled loser — dropped on the other's
1260    ///     accept — records nothing); its in-flight render is reaped by the
1261    ///     PoolGuard Drop reaper.
1262    #[allow(clippy::too_many_arguments)] // url/headers/wait/deadline/host mirror fetch_with_js
1263    async fn try_hedge(
1264        &self,
1265        lp: &Arc<dyn PageFetcher>,
1266        chrome: &Arc<dyn PageFetcher>,
1267        url: &str,
1268        headers: &HashMap<String, String>,
1269        wait_for_ms: Option<u64>,
1270        deadline: crw_core::Deadline,
1271        host: &str,
1272    ) -> CrwResult<Option<HedgeOutcome>> {
1273        // Breaker gates (mirror serial). If either tier's breaker is open, bail to
1274        // serial so its skip/leak-through handling applies.
1275        let (lp_permit, lp_guard) = self
1276            .breakers
1277            .acquire_with_guard(host, RendererKind::Lightpanda)
1278            .await;
1279        if lp_permit == Permit::Rejected {
1280            drop(lp_guard);
1281            return Ok(None);
1282        }
1283        let (ch_permit, ch_guard) = self
1284            .breakers
1285            .acquire_with_guard(host, RendererKind::Chrome)
1286            .await;
1287        if ch_permit == Permit::Rejected {
1288            drop(lp_guard);
1289            drop(ch_guard);
1290            return Ok(None);
1291        }
1292        let mut lp_guard = Some(lp_guard);
1293        let mut ch_guard = Some(ch_guard);
1294
1295        // Race both on the CURRENT task (select!, not spawn) so REQUEST_PROXY /
1296        // REQUEST_COUNTRY task-locals propagate into each fetch.
1297        let lp_fut = lp.fetch(url, headers, wait_for_ms, deadline);
1298        let chrome_fut = chrome.fetch(url, headers, wait_for_ms, deadline);
1299        tokio::pin!(lp_fut, chrome_fut);
1300        let (mut lp_done, mut ch_done) = (false, false);
1301        let mut lp_res: Option<CrwResult<FetchResult>> = None;
1302        let mut ch_res: Option<CrwResult<FetchResult>> = None;
1303        while !(lp_done && ch_done) {
1304            tokio::select! {
1305                biased;
1306                r = &mut lp_fut, if !lp_done => {
1307                    lp_done = true;
1308                    let accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1309                    lp_res = Some(r);
1310                    // Rule A: lightpanda authoritative — accept now, drop chrome.
1311                    if accept {
1312                        break;
1313                    }
1314                }
1315                r = &mut chrome_fut, if !ch_done => {
1316                    ch_done = true;
1317                    let ch_accept = matches!(&r, Ok(res) if self.classify_js_attempt(res).acceptable);
1318                    ch_res = Some(r);
1319                    // chrome may finish first; only accept it early once lightpanda
1320                    // is known NOT acceptable (else wait for lightpanda — Rule A).
1321                    if lp_done {
1322                        let lp_accept = matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1323                        if !lp_accept && ch_accept {
1324                            break;
1325                        }
1326                    }
1327                }
1328            }
1329        }
1330        // The still-pending future (if any) drops at scope end → PoolGuard reaper.
1331
1332        // Finalize. Record side-effects only for COMPLETED tiers (Some result).
1333        let lp_accept =
1334            matches!(&lp_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1335        let ch_accept =
1336            matches!(&ch_res, Some(Ok(res)) if self.classify_js_attempt(res).acceptable);
1337
1338        // Rule A: lightpanda wins if acceptable.
1339        if lp_accept {
1340            let mut r = lp_res.unwrap().unwrap();
1341            self.record_hedge_success(host, RendererKind::Lightpanda, &r, &mut lp_guard)
1342                .await;
1343            // chrome cancelled or thin → no record (Rule C).
1344            r.credit_cost = credit_for(RendererKind::Lightpanda);
1345            r.render_decision = Some(RenderDecision::AutoDefault {
1346                chosen: RendererKind::Lightpanda,
1347            });
1348            return Ok(Some(HedgeOutcome::Accepted(r)));
1349        }
1350        // lightpanda completed thin → record it (serial would have).
1351        let mut saw_hard_block = false;
1352        if let Some(Ok(res)) = &lp_res {
1353            let cls = self.classify_js_attempt(res);
1354            saw_hard_block |= cls.hard_block;
1355            self.record_hedge_thin(host, RendererKind::Lightpanda, &cls, &mut lp_guard)
1356                .await;
1357        }
1358        if ch_accept {
1359            let mut r = ch_res.unwrap().unwrap();
1360            self.record_hedge_success(host, RendererKind::Chrome, &r, &mut ch_guard)
1361                .await;
1362            r.credit_cost = credit_for(RendererKind::Chrome);
1363            r.render_decision = Some(RenderDecision::Failover {
1364                chain: vec![RendererKind::Lightpanda, RendererKind::Chrome],
1365                reason: FailoverErrorKind::Other,
1366            });
1367            return Ok(Some(HedgeOutcome::Accepted(r)));
1368        }
1369        // chrome completed thin → record it.
1370        if let Some(Ok(res)) = &ch_res {
1371            let cls = self.classify_js_attempt(res);
1372            saw_hard_block |= cls.hard_block;
1373            self.record_hedge_thin(host, RendererKind::Chrome, &cls, &mut ch_guard)
1374                .await;
1375        }
1376
1377        // Rule B: best-thin = richest HTML among completed Ok results.
1378        let thin = [lp_res, ch_res]
1379            .into_iter()
1380            .flatten()
1381            .filter_map(|r| r.ok())
1382            .max_by_key(|r| r.html.len());
1383        match thin {
1384            Some(r) => Ok(Some(HedgeOutcome::Thin(r, saw_hard_block))),
1385            // Both tiers errored — let the caller fall back to serial for its
1386            // richer error handling rather than inventing an error here.
1387            None => Ok(None),
1388        }
1389    }
1390
1391    /// Record a hedge winner's success side-effects (breaker + preference + guard).
1392    async fn record_hedge_success(
1393        &self,
1394        host: &str,
1395        k: RendererKind,
1396        result: &FetchResult,
1397        guard: &mut Option<ProbeGuard>,
1398    ) {
1399        if !host.is_empty() {
1400            let outcome = if result.truncated {
1401                BreakerOutcome::Truncated
1402            } else {
1403                BreakerOutcome::Success
1404            };
1405            self.breakers.record_outcome(host, k, outcome).await;
1406            self.preferences.record_success(host).await;
1407        }
1408        if let Some(g) = guard.take() {
1409            g.disarm();
1410        }
1411    }
1412
1413    /// Record a hedge thin/blocked tier's failure side-effects.
1414    async fn record_hedge_thin(
1415        &self,
1416        host: &str,
1417        k: RendererKind,
1418        cls: &JsAttemptClass,
1419        guard: &mut Option<ProbeGuard>,
1420    ) {
1421        if !host.is_empty() {
1422            self.breakers
1423                .record_outcome(host, k, BreakerOutcome::RenderError)
1424                .await;
1425            if k == RendererKind::Lightpanda {
1426                let err_kind = if cls.is_status_blocked || cls.is_bot_wall || cls.antibot_blocked {
1427                    FailoverErrorKind::AntibotBlock
1428                } else {
1429                    FailoverErrorKind::PlaceholderContent
1430                };
1431                let _ = self.preferences.record_failure(host, &err_kind).await;
1432            }
1433        }
1434        // Thin attempt → leave the probe guard armed (drops as a no-op).
1435        let _ = guard;
1436    }
1437
1438    async fn fetch_with_js(
1439        &self,
1440        url: &str,
1441        headers: &HashMap<String, String>,
1442        wait_for_ms: Option<u64>,
1443        requested_renderer: Option<&str>,
1444        deadline: crw_core::Deadline,
1445    ) -> CrwResult<FetchResult> {
1446        let host = host_of(url);
1447        let is_user_pinned = matches!(requested_renderer, Some(name) if name != "auto");
1448        if let Some(pinned) = requested_renderer
1449            && let Some(kind) = renderer_kind_for(pinned)
1450        {
1451            metrics()
1452                .user_pin_total
1453                .with_label_values(&[kind.as_str()])
1454                .inc();
1455        }
1456
1457        // Filter the JS pool down to a hard-pinned renderer when one was named.
1458        // "auto" or `None` means "use the configured chain".
1459        //
1460        // A pinned request (`Some(name)` where name != "auto") is matched by
1461        // exact name and BYPASSES the camoufox auto-exclusion — an explicit
1462        // `renderer = "camoufox"` pin always reaches the (constructed) tier even
1463        // when `include_in_auto = false`. The exclusion applies ONLY to the
1464        // non-pinned auto chain.
1465        let mut renderers: Vec<&Arc<dyn PageFetcher>> = match requested_renderer {
1466            Some(name) if name != "auto" => self
1467                .js_renderers
1468                .iter()
1469                .filter(|r| r.name() == name)
1470                .collect(),
1471            _ => {
1472                #[cfg(feature = "camoufox")]
1473                {
1474                    let in_auto = self.camoufox_in_auto;
1475                    self.js_renderers
1476                        .iter()
1477                        .filter(|r| in_auto || r.name() != "camoufox")
1478                        .collect()
1479                }
1480                #[cfg(not(feature = "camoufox"))]
1481                {
1482                    self.js_renderers.iter().collect()
1483                }
1484            }
1485        };
1486
1487        // LightPanda has no upstream-proxy support: when a proxy is active for
1488        // this request, drop it so the rotated/sticky egress IP is honored
1489        // (vanilla Chrome applies it via a per-context `proxyServer`). Fail
1490        // CLOSED — if filtering leaves no proxy-capable JS renderer, return a
1491        // hard error rather than silently navigating direct through LightPanda
1492        // and leaking the host's real IP.
1493        let proxy_active = REQUEST_PROXY.try_with(|p| p.is_some()).unwrap_or(false);
1494        if proxy_active {
1495            renderers.retain(|r| r.name() != "lightpanda");
1496            if renderers.is_empty() {
1497                return Err(CrwError::RendererError(
1498                    "a proxy is required for this request but the only available JS \
1499                     renderer (lightpanda) cannot route through a proxy; configure a \
1500                     chrome/chrome_proxy tier to use proxies with JS rendering"
1501                        .into(),
1502                ));
1503            }
1504        }
1505
1506        // Screenshot capture is CDP `Page.captureScreenshot` on vanilla Chrome.
1507        // LightPanda's CdpRenderer returns a ~30-byte stub and Camoufox is an
1508        // HTTP sidecar that doesn't speak CDP — neither can capture. Drop both
1509        // and fail CLOSED if that empties the chain, rather than returning a
1510        // screenshot-less result the caller asked for (mirrors the proxy retain
1511        // above). Applies even to a hard pin: pinning camoufox/lightpanda +
1512        // requesting a screenshot is unsatisfiable.
1513        if screenshot_requested() {
1514            renderers.retain(|r| r.name() != "lightpanda" && r.name() != "camoufox");
1515            if renderers.is_empty() {
1516                return Err(CrwError::RendererError(
1517                    "a screenshot was requested but no CDP-capable Chrome renderer is \
1518                     available; lightpanda and camoufox cannot capture screenshots — \
1519                     configure a chrome/chrome_proxy tier"
1520                        .into(),
1521                ));
1522            }
1523        }
1524        // Phase 2 (latency-qn): gated auto-egress. Pull chrome_proxy OUT of the
1525        // normal ladder and hold it as a hard-block-only recovery arm fired ONCE
1526        // after the ladder (below), with a reserved deadline budget. A naive
1527        // always-on chrome_proxy ladder tier is net-negative (bench: success
1528        // −2pp, p90 +69%) because the slow residential tier burns the deadline
1529        // on every escalation; gating it to genuine hard-blocks keeps the
1530        // recovery without the regression. Only in auto mode and when the
1531        // request isn't already proxied (that path wants chrome_proxy in-ladder).
1532        let auto_egress_arm: Option<Arc<dyn PageFetcher>> =
1533            if self.auto_egress_escalation && !is_user_pinned && !proxy_active {
1534                let arm = self
1535                    .js_renderers
1536                    .iter()
1537                    .find(|r| r.name() == "chrome_proxy")
1538                    .cloned();
1539                renderers.retain(|r| r.name() != "chrome_proxy");
1540                arm
1541            } else {
1542                None
1543            };
1544
1545        // Auto mode: if this host has been promoted, try Chrome first.
1546        if !is_user_pinned
1547            && let Some(RendererKind::Chrome) = self.preferences.preferred(&host).await
1548        {
1549            // 3-tier rank: chrome first, then the residential chrome_proxy,
1550            // then everything lighter. A stable binary key would yield
1551            // `[chrome, lightpanda, chrome_proxy]` — escalating a chrome
1552            // block to lightpanda (same WAF, lighter fingerprint) before
1553            // ever reaching the residential tier.
1554            renderers.sort_by_key(|r| match r.name() {
1555                "chrome" => 0,
1556                "chrome_proxy" => 1,
1557                _ => 2,
1558            });
1559            tracing::debug!(host = %host, "host promoted to chrome by preference learner");
1560        }
1561
1562        if renderers.is_empty() {
1563            let available = self.js_renderer_names();
1564            return Err(CrwError::RendererError(format!(
1565                "requested renderer '{}' not in pool [{}]",
1566                requested_renderer.unwrap_or("auto"),
1567                available.join(", ")
1568            )));
1569        }
1570
1571        // Track the chain we attempted so we can populate
1572        // `RenderDecision::Failover` when nothing succeeded outright.
1573        let mut chain: Vec<RendererKind> = Vec::new();
1574        let mut breaker_skipped: Vec<RendererKind> = Vec::new();
1575        let mut last_error = None;
1576        let mut last_failover_reason: Option<FailoverErrorKind> = None;
1577        let mut thin_result: Option<FetchResult> = None;
1578        // Phase 2: did any ladder attempt end in a hard block (egress-recoverable
1579        // subset: 401/403/429/503/520-530 or a bot-wall/vendor/antibot wall)?
1580        // Drives the gated chrome_proxy recovery arm below. Excludes
1581        // 404/410/412/451/500 (a different egress IP won't fix those).
1582        let mut saw_hard_block = false;
1583        // Snapshot for the leak-through fallback below. The main loop
1584        // consumes `renderers`; we keep a parallel reference list so a
1585        // single skipped renderer can still get a shot when its host
1586        // breaker is closed.
1587        let renderers_snapshot: Vec<&Arc<dyn PageFetcher>> = renderers.clone();
1588
1589        // latency-qn conditional hedge: when lightpanda is first (cheap-first, not
1590        // promoted to chrome) and chrome is present, race them concurrently so
1591        // chrome's render clock starts immediately instead of after lightpanda
1592        // fails. Headroom-gated (try_acquire) so it can't deadlock the pool; on no
1593        // permit / open breaker / both-errored it falls through to the serial loop.
1594        let mut hedge_done = false;
1595        if self.chrome_hedge
1596            && !is_user_pinned
1597            && !proxy_active
1598            && renderers.first().map(|r| r.name()) == Some("lightpanda")
1599            && renderers.iter().any(|r| r.name() == "chrome")
1600            && let Ok(_permit) = self.hedge_sem.clone().try_acquire_owned()
1601        {
1602            let lp = renderers
1603                .iter()
1604                .find(|r| r.name() == "lightpanda")
1605                .expect("checked above");
1606            let chrome = renderers
1607                .iter()
1608                .find(|r| r.name() == "chrome")
1609                .expect("checked above");
1610            match self
1611                .try_hedge(lp, chrome, url, headers, wait_for_ms, deadline, &host)
1612                .await
1613            {
1614                Ok(Some(HedgeOutcome::Accepted(r))) => return Ok(r),
1615                Ok(Some(HedgeOutcome::Thin(r, hb))) => {
1616                    thin_result = Some(r);
1617                    saw_hard_block |= hb;
1618                    chain.push(RendererKind::Lightpanda);
1619                    chain.push(RendererKind::Chrome);
1620                    hedge_done = true;
1621                }
1622                // breaker open / both-errored → fall back to the serial loop.
1623                Ok(None) => {}
1624                Err(e) => last_error = Some(e),
1625            }
1626        }
1627
1628        for renderer in renderers {
1629            if hedge_done {
1630                break;
1631            }
1632            let kind = renderer_kind_for(renderer.name());
1633
1634            // Skip empty hosts: don't pollute breaker/preference caches
1635            // with the "" key when URL parsing failed.
1636            let trackable = kind.filter(|_| !host.is_empty());
1637
1638            // Pre-flight deadline skip removed: classify_outcome in the
1639            // breaker layer already ignores DeadlineClamped outcomes, so a
1640            // tier-side timeout on near-exhausted budget doesn't poison the
1641            // breaker. Letting chrome attempt with partial-DOM budget gives
1642            // higher success on legitimately-slow tail URLs than aborting
1643            // pre-flight. tier_timeouts is still used by AttemptContext to
1644            // detect clamping post-hoc.
1645
1646            // Consult breaker for tracked renderers. Untracked names (e.g.
1647            // "playwright") bypass the breaker for now.
1648            let mut probe_guard: Option<ProbeGuard> = None;
1649            if let Some(k) = trackable {
1650                let (permit, guard) = self.breakers.acquire_with_guard(&host, k).await;
1651                if permit == Permit::Rejected {
1652                    tracing::info!(
1653                        renderer = renderer.name(),
1654                        host = %host,
1655                        "circuit breaker open, skipping renderer"
1656                    );
1657                    metrics()
1658                        .render_route_decision_total
1659                        .with_label_values(&[k.as_str(), "breakerSkipped"])
1660                        .inc();
1661                    breaker_skipped.push(k);
1662                    drop(guard); // not Probe — drop is a no-op
1663                    continue;
1664                }
1665                probe_guard = Some(guard);
1666            }
1667            if let Some(k) = kind {
1668                chain.push(k);
1669            }
1670
1671            // Capture pre-call context so post-await classification is
1672            // race-free against deadline drift.
1673            let attempt_ctx = {
1674                let remaining = deadline.remaining();
1675                let tier_budget = kind
1676                    .and_then(|k| self.tier_timeouts.get(&k).copied())
1677                    .unwrap_or(remaining);
1678                AttemptContext::capture(remaining, tier_budget)
1679            };
1680            // Phase 1 (latency-qn): per-attempt timing. The whole-fetch wrapper
1681            // only records total + accepted tier; this records each tier's
1682            // wall time + outcome so a bench run can tell whether the p90 tail
1683            // is stacked failed-tier time (a hedge would cut it) or the final
1684            // accepted render itself (a hedge would NOT). Feeds the Phase 1.5
1685            // kill-gate. Off in prod (gated by `latency_breakdown`).
1686            let attempt_start = std::time::Instant::now();
1687            let attempt_outcome = renderer.fetch(url, headers, wait_for_ms, deadline).await;
1688            if self.latency_breakdown {
1689                let attempt_ms = attempt_start.elapsed().as_millis() as u64;
1690                let tier = renderer.name();
1691                match &attempt_outcome {
1692                    Ok(r) => tracing::info!(
1693                        target: "latency_breakdown",
1694                        url, tier, attempt_ms,
1695                        status = r.status_code,
1696                        html_len = r.html.len(),
1697                        "hedge attempt"
1698                    ),
1699                    Err(e) => tracing::info!(
1700                        target: "latency_breakdown",
1701                        url, tier, attempt_ms,
1702                        error = %e,
1703                        "hedge attempt (error)"
1704                    ),
1705                }
1706            }
1707            match attempt_outcome {
1708                Ok(mut result) => {
1709                    let text_len = html_body_text_len(&result.html);
1710                    let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
1711                    let failed_render = detector::looks_like_failed_render(&result.html);
1712                    let is_bot_wall = detector::looks_like_generic_bot_wall(&result.html);
1713                    let vendor_block = detector::looks_like_vendor_block(&result.html);
1714                    // Mirrors the HTTP-tier escalation set (lib.rs:658). A JS
1715                    // renderer can return 200 with bot HTML or 403 with content
1716                    // — without this check, both slip through as "valid".
1717                    let is_status_blocked = matches!(
1718                        result.status_code,
1719                        401 | 403 | 404 | 405 | 406 | 410 | 412 | 429 | 451 | 500 | 503
1720                    );
1721                    // The comprehensive 3-tier antibot classifier. The
1722                    // `detector` heuristics above only know a fixed phrase
1723                    // list + 8 named vendors; `classify()` additionally
1724                    // recognises Reddit-class WAF pages ("blocked by network
1725                    // security") served with HTTP 200 that otherwise slip
1726                    // through as success. Always runs for telemetry when
1727                    // `enabled`; only forces escalation when
1728                    // `escalate_in_failover` is on (the kill switch).
1729                    let antibot = if self.antibot.enabled {
1730                        crw_extract::antibot::classify(Some(result.status_code), &result.html)
1731                    } else {
1732                        crw_extract::antibot::AntibotResult::none()
1733                    };
1734                    let antibot_blocked =
1735                        self.antibot.escalate_in_failover && antibot.signal.is_blocked();
1736                    // Phase 2: track hard-block (egress-recoverable) outcomes for
1737                    // the gated chrome_proxy arm. Hard-block status subset only
1738                    // (not 404/410/412/451/500) + interstitial walls.
1739                    if matches!(result.status_code, 401 | 403 | 429 | 503)
1740                        || (520..=530).contains(&result.status_code)
1741                        || is_bot_wall
1742                        || vendor_block.is_some()
1743                        || antibot.signal.is_blocked()
1744                    {
1745                        saw_hard_block = true;
1746                    }
1747                    if text_len >= Self::MIN_RENDERED_TEXT_LEN
1748                        && !is_placeholder
1749                        && failed_render.is_none()
1750                        && !is_bot_wall
1751                        && vendor_block.is_none()
1752                        && !is_status_blocked
1753                        && !antibot_blocked
1754                    {
1755                        // Capture the promotion state BEFORE record_success
1756                        // clears the latch — otherwise AutoPromoted decisions
1757                        // race against the success path and downgrade to AutoDefault.
1758                        let was_promoted = matches!(
1759                            self.preferences.preferred(&host).await,
1760                            Some(RendererKind::Chrome)
1761                        );
1762                        if let Some(k) = trackable {
1763                            // Treat truncated-but-valid as Truncated (ignored
1764                            // by default per BreakerConfig.count_truncated_as_failure).
1765                            let outcome = if result.truncated {
1766                                BreakerOutcome::Truncated
1767                            } else {
1768                                BreakerOutcome::Success
1769                            };
1770                            self.breakers.record_outcome(&host, k, outcome).await;
1771                            self.preferences.record_success(&host).await;
1772                            metrics()
1773                                .render_route_decision_total
1774                                .with_label_values(&[k.as_str(), "success"])
1775                                .inc();
1776                            metrics()
1777                                .host_preferences_size
1778                                .set(self.preferences.size() as i64);
1779                        }
1780                        if let Some(g) = probe_guard.take() {
1781                            g.disarm();
1782                        }
1783                        // Populate routing metadata + per-renderer credit.
1784                        if let Some(k) = kind {
1785                            result.credit_cost = credit_for(k);
1786                            result.render_decision = Some(if is_user_pinned {
1787                                RenderDecision::UserPinned { renderer: k }
1788                            } else if !breaker_skipped.is_empty() {
1789                                RenderDecision::BreakerSkipped {
1790                                    skipped: breaker_skipped[0],
1791                                    chosen: k,
1792                                }
1793                            } else if chain.len() > 1 {
1794                                RenderDecision::Failover {
1795                                    chain: chain.clone(),
1796                                    reason: last_failover_reason
1797                                        .clone()
1798                                        .unwrap_or(FailoverErrorKind::Other),
1799                                }
1800                            } else if was_promoted && k == RendererKind::Chrome {
1801                                RenderDecision::AutoPromoted {
1802                                    chosen: k,
1803                                    from: RendererKind::Lightpanda,
1804                                    reason: "host preference learner".into(),
1805                                }
1806                            } else {
1807                                RenderDecision::AutoDefault { chosen: k }
1808                            });
1809                        }
1810                        return Ok(result);
1811                    }
1812                    // Treat thin/placeholder/failed as a soft failure for
1813                    // breaker + preference purposes.
1814                    let err_kind = match failed_render {
1815                        Some(detector::FailedRenderReason::NextJsClientError) => {
1816                            FailoverErrorKind::NextJsClientError
1817                        }
1818                        Some(detector::FailedRenderReason::ReactMinifiedError) => {
1819                            FailoverErrorKind::NextJsClientError
1820                        }
1821                        Some(detector::FailedRenderReason::EmptyNextRoot) => {
1822                            FailoverErrorKind::EmptyNextRoot
1823                        }
1824                        None if vendor_block.is_some() => FailoverErrorKind::VendorBlock,
1825                        None if is_status_blocked => FailoverErrorKind::StatusBlocked,
1826                        None if is_placeholder => FailoverErrorKind::PlaceholderContent,
1827                        None if is_bot_wall => FailoverErrorKind::PlaceholderContent,
1828                        // The classifier caught a block the detector missed.
1829                        None if antibot_blocked => FailoverErrorKind::AntibotBlock,
1830                        None => FailoverErrorKind::PlaceholderContent,
1831                    };
1832                    last_failover_reason = Some(err_kind.clone());
1833                    if let Some(k) = trackable {
1834                        // Thin/placeholder/failed render → classify against
1835                        // attempt context so deadline-clamped attempts don't
1836                        // poison the breaker.
1837                        let outcome = classify_outcome(false, false, false, &attempt_ctx);
1838                        self.breakers.record_outcome(&host, k, outcome).await;
1839                        if k == RendererKind::Lightpanda
1840                            && let Some(target) =
1841                                self.preferences.record_failure(&host, &err_kind).await
1842                        {
1843                            metrics()
1844                                .host_preferences_promotions_total
1845                                .with_label_values(&[k.as_str(), target.as_str()])
1846                                .inc();
1847                            tracing::info!(
1848                                host = %host,
1849                                "host promoted by preference learner: {} -> {}",
1850                                k.as_str(),
1851                                target.as_str()
1852                            );
1853                        }
1854                    }
1855                    if let Some(g) = probe_guard.take() {
1856                        g.disarm();
1857                    }
1858                    if let Some(vendor) = vendor_block {
1859                        metrics()
1860                            .vendor_block_total
1861                            .with_label_values(&[vendor])
1862                            .inc();
1863                        tracing::warn!(
1864                            renderer = renderer.name(),
1865                            url,
1866                            vendor,
1867                            "vendor anti-bot block detected"
1868                        );
1869                    }
1870                    // Emit the antibot signal regardless of `escalate_in_failover`
1871                    // — a pre-flip dashboard of escalation pressure.
1872                    if antibot.signal.is_blocked() {
1873                        metrics()
1874                            .antibot_escalation_total
1875                            .with_label_values(&[antibot.signal.class_name()])
1876                            .inc();
1877                        tracing::warn!(
1878                            renderer = renderer.name(),
1879                            url,
1880                            signal = antibot.signal.class_name(),
1881                            reason = %antibot.reason,
1882                            status_code = result.status_code,
1883                            text_len,
1884                            escalated = antibot_blocked,
1885                            "antibot classifier flagged a block"
1886                        );
1887                    }
1888                    tracing::info!(
1889                        renderer = renderer.name(),
1890                        text_len,
1891                        is_placeholder,
1892                        is_bot_wall,
1893                        vendor_block,
1894                        is_status_blocked,
1895                        antibot_signal = antibot.signal.class_name(),
1896                        antibot_blocked,
1897                        status_code = result.status_code,
1898                        failed_render = ?failed_render,
1899                        "JS renderer returned thin/placeholder/failed content, trying next renderer"
1900                    );
1901                    // Annotate the result so it can surface through `thin_result`
1902                    // if no later renderer succeeds. Preserves any warning the
1903                    // renderer set, but adds the failover reason. We keep the
1904                    // first thin result as the body to return (no point in
1905                    // accumulating bodies), but stitch later renderers'
1906                    // warnings onto it so debug output reflects every attempt.
1907                    let mut annotated = result;
1908                    let attempt_warning = if let Some(reason) = failed_render {
1909                        format!(
1910                            "{} returned a failed render ({})",
1911                            renderer.name(),
1912                            reason.as_str()
1913                        )
1914                    } else if is_placeholder {
1915                        format!("{} returned a loading placeholder", renderer.name())
1916                    } else if let Some(vendor) = vendor_block {
1917                        format!(
1918                            "{} returned a vendor anti-bot block ({vendor})",
1919                            renderer.name()
1920                        )
1921                    } else if is_bot_wall {
1922                        format!(
1923                            "{} returned a generic anti-bot interstitial",
1924                            renderer.name()
1925                        )
1926                    } else if is_status_blocked {
1927                        format!(
1928                            "{} returned HTTP {} (treated as blocked)",
1929                            renderer.name(),
1930                            annotated.status_code
1931                        )
1932                    } else if antibot_blocked {
1933                        format!(
1934                            "{} returned an anti-bot block ({}: {})",
1935                            renderer.name(),
1936                            antibot.signal.class_name(),
1937                            antibot.reason
1938                        )
1939                    } else {
1940                        format!(
1941                            "{} returned thin content (text_len={text_len})",
1942                            renderer.name()
1943                        )
1944                    };
1945                    if is_bot_wall || vendor_block.is_some() || is_status_blocked || antibot_blocked
1946                    {
1947                        // Surface bot-wall as a RendererError so, if every
1948                        // renderer in the chain hits a wall, the final error
1949                        // (line ~1052) carries an actionable message.
1950                        // RendererError maps to FailoverErrorKind::LightpandaCrash
1951                        // via classify_renderer_error — that's intentional:
1952                        // bot-wall hosts SHOULD be promoted to Chrome by the
1953                        // host preference learner, since LightPanda lacks the
1954                        // TLS/header fingerprint to clear them.
1955                        let msg = if let Some(v) = vendor_block {
1956                            format!("{} returned a vendor anti-bot block ({v})", renderer.name())
1957                        } else if is_status_blocked {
1958                            format!(
1959                                "{} returned HTTP {} (treated as blocked)",
1960                                renderer.name(),
1961                                annotated.status_code
1962                            )
1963                        } else if is_bot_wall {
1964                            format!(
1965                                "{} returned a generic anti-bot interstitial",
1966                                renderer.name()
1967                            )
1968                        } else {
1969                            format!(
1970                                "{} returned an anti-bot block ({}: {})",
1971                                renderer.name(),
1972                                antibot.signal.class_name(),
1973                                antibot.reason
1974                            )
1975                        };
1976                        last_error = Some(CrwError::RendererError(msg));
1977                    }
1978                    annotated.warnings.push(attempt_warning.clone());
1979                    annotated.warning = Some(match annotated.warning {
1980                        Some(prev) => format!("{prev}; {attempt_warning}"),
1981                        None => attempt_warning.clone(),
1982                    });
1983                    thin_result = Some(match thin_result {
1984                        None => annotated,
1985                        Some(existing) => {
1986                            // Prefer the larger HTML when stitching thin
1987                            // results — a later renderer (e.g. chrome) often
1988                            // returns a CAPTCHA shell that, while small,
1989                            // contains anti-bot markers absent from an even
1990                            // smaller earlier shell. Diagnostics & block
1991                            // detection then have something to match on.
1992                            let (mut keeper, dropped) =
1993                                if annotated.html.len() > existing.html.len() {
1994                                    (annotated, existing)
1995                                } else {
1996                                    (existing, annotated)
1997                                };
1998                            keeper.warnings.push(attempt_warning.clone());
1999                            keeper.warning = Some(match keeper.warning {
2000                                Some(prev) => format!("{prev}; {attempt_warning}"),
2001                                None => attempt_warning,
2002                            });
2003                            // Carry over any extra warnings from the dropped
2004                            // attempt so debug output stays complete.
2005                            for w in dropped.warnings {
2006                                if !keeper.warnings.contains(&w) {
2007                                    keeper.warnings.push(w);
2008                                }
2009                            }
2010                            keeper
2011                        }
2012                    });
2013                }
2014                Err(e) => {
2015                    tracing::warn!(renderer = renderer.name(), "JS renderer failed: {e}");
2016                    let err_kind = classify_renderer_error(&e);
2017                    last_failover_reason = Some(err_kind.clone());
2018                    if let Some(k) = trackable {
2019                        let was_timeout = matches!(e, CrwError::Timeout(_));
2020                        let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
2021                        self.breakers.record_outcome(&host, k, outcome).await;
2022                        if k == RendererKind::Lightpanda {
2023                            let _ = self.preferences.record_failure(&host, &err_kind).await;
2024                        }
2025                    }
2026                    if let Some(g) = probe_guard.take() {
2027                        g.disarm();
2028                    }
2029                    last_error = Some(e);
2030                    continue;
2031                }
2032            }
2033        }
2034        // Leak-through fallback: every renderer was rejected by the global
2035        // breaker, but the host itself has no failures recorded. Rather
2036        // than fail the request outright (which is what made the bench
2037        // shed ~12% on broad lightpanda outages), give one renderer a
2038        // single attempt without recording its outcome to the global
2039        // window. The host tier still records, so a host that's actually
2040        // broken trips its own breaker on the next attempt.
2041        // Trigger when every chain attempt failed outright (no thin_result,
2042        // no Ok return) AND at least one renderer was skipped by the global
2043        // breaker. Common case: lightpanda runs and errors, chrome gets
2044        // globally rejected → without leak we'd return error even though
2045        // chrome's host breaker is clean and would likely succeed.
2046        //
2047        // Skip when the request deadline is already (near-)exhausted:
2048        // entering a renderer with <500ms budget produced 37/128 of the
2049        // first leak run's failures as "Timeout after 1-2ms" — the
2050        // attempt cannot succeed and just consumes a CDP connection.
2051        const LEAK_MIN_BUDGET: Duration = Duration::from_millis(500);
2052        if thin_result.is_none()
2053            && !breaker_skipped.is_empty()
2054            && !is_user_pinned
2055            && deadline.remaining() >= LEAK_MIN_BUDGET
2056        {
2057            for renderer in &renderers_snapshot {
2058                let kind = renderer_kind_for(renderer.name());
2059                let trackable = kind.filter(|_| !host.is_empty());
2060                let Some(k) = trackable else { continue };
2061                if !breaker_skipped.contains(&k) {
2062                    continue;
2063                }
2064                let permit = self.breakers.try_acquire_host_only(&host, k).await;
2065                if permit == Permit::Rejected {
2066                    continue;
2067                }
2068                tracing::info!(
2069                    renderer = renderer.name(),
2070                    host = %host,
2071                    "global breaker open, host clean — leaking through one attempt"
2072                );
2073                metrics()
2074                    .render_route_decision_total
2075                    .with_label_values(&[k.as_str(), "leakThrough"])
2076                    .inc();
2077                let attempt_ctx = {
2078                    let remaining = deadline.remaining();
2079                    let tier_budget = self.tier_timeouts.get(&k).copied().unwrap_or(remaining);
2080                    AttemptContext::capture(remaining, tier_budget)
2081                };
2082                let res = renderer.fetch(url, headers, wait_for_ms, deadline).await;
2083                match res {
2084                    Ok(mut result) => {
2085                        let text_len = html_body_text_len(&result.html);
2086                        let is_placeholder = detector::looks_like_loading_placeholder(&result.html);
2087                        let failed_render = detector::looks_like_failed_render(&result.html);
2088                        let truncated = result.truncated;
2089                        let content_ok = text_len >= Self::MIN_RENDERED_TEXT_LEN
2090                            && !is_placeholder
2091                            && failed_render.is_none();
2092                        let outcome = classify_outcome(content_ok, truncated, false, &attempt_ctx);
2093                        // Record host only — global stays untouched so the
2094                        // existing trip can finish its cooldown naturally.
2095                        self.breakers
2096                            .record_scoped_outcome(&host, k, None, Some(outcome))
2097                            .await;
2098                        if content_ok {
2099                            result.credit_cost = credit_for(k);
2100                            result.render_decision =
2101                                Some(RenderDecision::AutoDefault { chosen: k });
2102                            return Ok(result);
2103                        }
2104                        // Thin/placeholder on leak path → fall through to
2105                        // the normal "no JS renderer" return below.
2106                        last_error = Some(CrwError::RendererError(format!(
2107                            "leak attempt on {} returned thin content (text_len={text_len})",
2108                            renderer.name()
2109                        )));
2110                        break;
2111                    }
2112                    Err(e) => {
2113                        let was_timeout = matches!(e, CrwError::Timeout(_));
2114                        let outcome = classify_outcome(false, false, was_timeout, &attempt_ctx);
2115                        self.breakers
2116                            .record_scoped_outcome(&host, k, None, Some(outcome))
2117                            .await;
2118                        last_error = Some(e);
2119                        break;
2120                    }
2121                }
2122            }
2123        }
2124
2125        // Phase 2 (latency-qn): gated auto-egress recovery. chrome_proxy was held
2126        // out of the ladder; fire it ONCE iff the ladder hit a hard block AND the
2127        // deadline can still absorb a full chrome_proxy attempt (so it never
2128        // causes a timeout the baseline wouldn't have — the failure mode the
2129        // naive always-on ladder tier showed: success −2pp, p90 +69%).
2130        // best-result-wins: never replace usable content with an empty retry.
2131        if let Some(arm) = auto_egress_arm {
2132            let kind = RendererKind::ChromeProxy;
2133            let tier_budget = self
2134                .tier_timeouts
2135                .get(&kind)
2136                .copied()
2137                .unwrap_or_else(|| std::time::Duration::from_secs(30));
2138            if saw_hard_block && deadline.remaining() >= tier_budget {
2139                chain.push(kind);
2140                let entry = self.pick_proxy_for_url(url);
2141                let attempt = REQUEST_PROXY
2142                    .scope(entry, arm.fetch(url, headers, wait_for_ms, deadline))
2143                    .await;
2144                match attempt {
2145                    Ok(r) => {
2146                        let r_text = html_body_text_len(&r.html);
2147                        let r_ok = r_text >= Self::MIN_RENDERED_TEXT_LEN
2148                            && detector::looks_like_failed_render(&r.html).is_none()
2149                            && !detector::looks_like_loading_placeholder(&r.html);
2150                        if !host.is_empty() {
2151                            let outcome = if r_ok {
2152                                BreakerOutcome::Success
2153                            } else {
2154                                BreakerOutcome::RenderError
2155                            };
2156                            self.breakers.record_outcome(&host, kind, outcome).await;
2157                        }
2158                        // best-result-wins vs the ladder's thin_result: ONLY take
2159                        // the proxy result if it is content-OK (red line: a thin/
2160                        // empty proxy result must never turn a baseline Err into an
2161                        // Ok(empty), nor replace a usable thin_result). Code-review
2162                        // 🔴#1: gate `None` case on r_ok too, else all-tiers-errored
2163                        // would ship an empty proxy body as success.
2164                        let better = r_ok
2165                            && match &thin_result {
2166                                Some(prev) => r.html.len() > prev.html.len(),
2167                                None => true,
2168                            };
2169                        if self.latency_breakdown {
2170                            tracing::info!(
2171                                target: "latency_breakdown",
2172                                url, tier = "chrome_proxy",
2173                                ok = r_ok, consumed = better,
2174                                "auto_egress fired"
2175                            );
2176                        }
2177                        if better {
2178                            thin_result = Some(r);
2179                        }
2180                    }
2181                    Err(e) => {
2182                        if !host.is_empty() {
2183                            self.breakers
2184                                .record_outcome(&host, kind, BreakerOutcome::ConnectionError)
2185                                .await;
2186                        }
2187                        if self.latency_breakdown {
2188                            tracing::info!(
2189                                target: "latency_breakdown",
2190                                url, tier = "chrome_proxy", error = %e,
2191                                "auto_egress fired (error)"
2192                            );
2193                        }
2194                    }
2195                }
2196            }
2197        }
2198
2199        // Return the best thin result if we have one, otherwise the last error.
2200        if let Some(mut result) = thin_result {
2201            // Stamp routing metadata on the soft-failure result too — callers
2202            // need to know which chain was attempted for debugging.
2203            if let Some(last) = chain.last().copied() {
2204                result.credit_cost = credit_for(last);
2205                result.render_decision = Some(RenderDecision::Failover {
2206                    chain: chain.clone(),
2207                    reason: last_failover_reason
2208                        .clone()
2209                        .unwrap_or(FailoverErrorKind::Other),
2210                });
2211            }
2212            // When the user hard-pinned a single renderer and it failed thin,
2213            // failover never ran — surface an actionable hint so callers (SaaS
2214            // playground, CLI, MCP) can show a banner instead of silently
2215            // returning broken markdown with `success: true`.
2216            if is_user_pinned
2217                && chain.len() == 1
2218                && let Some(pinned) = chain.first().copied()
2219            {
2220                let reason = last_failover_reason
2221                    .as_ref()
2222                    .map(|r| r.as_str())
2223                    .unwrap_or("unknown");
2224                let hint = format!(
2225                    "Pinned renderer '{}' returned a failed render ({}). Content may be unreliable. Retry with renderer=\"chrome\" or omit the renderer field for auto-failover.",
2226                    pinned.as_str(),
2227                    reason,
2228                );
2229                result.warnings.push(hint);
2230            }
2231            Ok(result)
2232        } else {
2233            Err(last_error
2234                .unwrap_or_else(|| CrwError::RendererError("No JS renderer available".to_string())))
2235        }
2236    }
2237
2238    /// Check availability of all renderers.
2239    pub async fn check_health(&self) -> HashMap<String, bool> {
2240        let mut health = HashMap::new();
2241        health.insert("http".to_string(), self.http.is_available().await);
2242        for r in &self.js_renderers {
2243            health.insert(r.name().to_string(), r.is_available().await);
2244        }
2245        health
2246    }
2247}
2248
2249/// Rough estimate of visible text length in an HTML document.
2250/// Strips tags and collapses whitespace. Used to detect "thin" renders
2251/// where a renderer returned HTML but failed to execute JavaScript.
2252fn html_body_text_len(html: &str) -> usize {
2253    // Extract body content if present, otherwise use entire HTML.
2254    let body = if let Some(start) = html.find("<body") {
2255        let start = html[start..].find('>').map(|i| start + i + 1).unwrap_or(0);
2256        let end = html.find("</body>").unwrap_or(html.len());
2257        &html[start..end]
2258    } else {
2259        html
2260    };
2261    // Strip tags crudely.
2262    let mut in_tag = false;
2263    let mut text_len = 0;
2264    let mut prev_ws = true;
2265    for ch in body.chars() {
2266        if ch == '<' {
2267            in_tag = true;
2268        } else if ch == '>' {
2269            in_tag = false;
2270        } else if !in_tag {
2271            if ch.is_whitespace() {
2272                if !prev_ws {
2273                    text_len += 1;
2274                    prev_ws = true;
2275                }
2276            } else {
2277                text_len += 1;
2278                prev_ws = false;
2279            }
2280        }
2281    }
2282    text_len
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287    use super::*;
2288    use crate::breaker::BreakerConfig;
2289    #[cfg(feature = "camoufox")]
2290    use crw_core::config::CamoufoxEndpoint;
2291    #[cfg(feature = "cdp")]
2292    use crw_core::config::CdpEndpoint;
2293    use std::time::Duration;
2294
2295    /// Generous deadline used by tests that don't care about budget enforcement.
2296    fn tdl() -> crw_core::Deadline {
2297        crw_core::Deadline::now_plus(Duration::from_secs(60))
2298    }
2299
2300    fn base_cfg(mode: RendererMode) -> RendererConfig {
2301        RendererConfig {
2302            mode,
2303            ..Default::default()
2304        }
2305    }
2306
2307    #[test]
2308    fn new_mode_none_ok_no_js_renderers() {
2309        let cfg = base_cfg(RendererMode::None);
2310        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2311        assert!(r.js_renderer_names().is_empty());
2312        assert_eq!(r.render_js_default, None);
2313    }
2314
2315    #[test]
2316    fn new_mode_auto_no_endpoints_ok_http_only() {
2317        let cfg = base_cfg(RendererMode::Auto);
2318        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2319        assert!(r.js_renderer_names().is_empty());
2320    }
2321
2322    #[cfg(feature = "cdp")]
2323    #[test]
2324    fn new_mode_chrome_without_endpoint_errors() {
2325        let cfg = base_cfg(RendererMode::Chrome);
2326        let err =
2327            FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2328        let msg = err.to_string().to_lowercase();
2329        assert!(msg.contains("chrome"), "expected chrome in error: {msg}");
2330        assert!(
2331            msg.contains("ws_url") || msg.contains("not configured"),
2332            "expected ws_url hint in error: {msg}"
2333        );
2334    }
2335
2336    #[cfg(feature = "cdp")]
2337    #[test]
2338    fn new_mode_chrome_with_endpoint_ok_only_chrome() {
2339        let cfg = RendererConfig {
2340            mode: RendererMode::Chrome,
2341            chrome: Some(CdpEndpoint {
2342                ws_url: "ws://127.0.0.1:9222/".into(),
2343            }),
2344            lightpanda: Some(CdpEndpoint {
2345                ws_url: "ws://127.0.0.1:9223/".into(),
2346            }),
2347            ..Default::default()
2348        };
2349        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2350        assert_eq!(r.js_renderer_names(), vec!["chrome"]);
2351    }
2352
2353    #[cfg(feature = "cdp")]
2354    #[test]
2355    fn new_mode_lightpanda_without_endpoint_errors() {
2356        let cfg = base_cfg(RendererMode::Lightpanda);
2357        let err =
2358            FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2359        assert!(err.to_string().to_lowercase().contains("lightpanda"));
2360    }
2361
2362    #[cfg(feature = "cdp")]
2363    #[test]
2364    fn new_mode_auto_with_both_endpoints_preserves_order() {
2365        let cfg = RendererConfig {
2366            mode: RendererMode::Auto,
2367            lightpanda: Some(CdpEndpoint {
2368                ws_url: "ws://127.0.0.1:9222/".into(),
2369            }),
2370            chrome: Some(CdpEndpoint {
2371                ws_url: "ws://127.0.0.1:9223/".into(),
2372            }),
2373            ..Default::default()
2374        };
2375        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2376        assert_eq!(r.js_renderer_names(), vec!["lightpanda", "chrome"]);
2377    }
2378
2379    #[cfg(feature = "cdp")]
2380    #[test]
2381    fn ladder_includes_chrome_proxy_when_configured() {
2382        let cfg = RendererConfig {
2383            mode: RendererMode::Auto,
2384            lightpanda: Some(CdpEndpoint {
2385                ws_url: "ws://127.0.0.1:9222/".into(),
2386            }),
2387            chrome: Some(CdpEndpoint {
2388                ws_url: "ws://127.0.0.1:9223/".into(),
2389            }),
2390            chrome_proxy: Some(CdpEndpoint {
2391                ws_url: "ws://127.0.0.1:9224/".into(),
2392            }),
2393            ..Default::default()
2394        };
2395        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2396        // chrome_proxy must be the LAST tier — fallback chain tries Chrome
2397        // direct first and only falls through to the proxy on Chrome failure.
2398        assert_eq!(
2399            r.js_renderer_names(),
2400            vec!["lightpanda", "chrome", "chrome_proxy"]
2401        );
2402    }
2403
2404    #[cfg(feature = "cdp")]
2405    #[test]
2406    fn ladder_omits_chrome_proxy_when_not_configured() {
2407        let cfg = RendererConfig {
2408            mode: RendererMode::Auto,
2409            chrome: Some(CdpEndpoint {
2410                ws_url: "ws://127.0.0.1:9223/".into(),
2411            }),
2412            chrome_proxy: None,
2413            ..Default::default()
2414        };
2415        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2416        assert!(!r.js_renderer_names().contains(&"chrome_proxy"));
2417    }
2418
2419    #[cfg(not(feature = "cdp"))]
2420    #[test]
2421    fn new_mode_chrome_errors_without_cdp_feature() {
2422        let cfg = base_cfg(RendererMode::Chrome);
2423        let err =
2424            FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2425        let msg = err.to_string().to_lowercase();
2426        assert!(msg.contains("cdp"), "expected cdp in error: {msg}");
2427    }
2428
2429    #[cfg(feature = "camoufox")]
2430    fn camoufox_cfg(mode: RendererMode, include_in_auto: bool) -> RendererConfig {
2431        RendererConfig {
2432            mode,
2433            camoufox: Some(CamoufoxEndpoint {
2434                base_url: "http://127.0.0.1:9377".into(),
2435                api_key: String::new(),
2436                include_in_auto,
2437            }),
2438            ..Default::default()
2439        }
2440    }
2441
2442    /// Opt-in default: a configured endpoint is CONSTRUCTED (so an explicit
2443    /// `renderer = "camoufox"` pin can reach it) but does NOT join the auto
2444    /// ladder when `include_in_auto = false`.
2445    #[cfg(feature = "camoufox")]
2446    #[test]
2447    fn camoufox_constructed_for_pin_but_excluded_from_auto() {
2448        let cfg = camoufox_cfg(RendererMode::Auto, false);
2449        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2450        assert!(
2451            r.js_renderer_names().contains(&"camoufox"),
2452            "configured camoufox must be constructed for pin-reachability"
2453        );
2454        assert!(
2455            !r.camoufox_in_auto,
2456            "include_in_auto=false must keep camoufox out of the auto ladder"
2457        );
2458    }
2459
2460    #[cfg(feature = "camoufox")]
2461    #[test]
2462    fn camoufox_joins_auto_when_include_in_auto_true() {
2463        let cfg = camoufox_cfg(RendererMode::Auto, true);
2464        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2465        assert!(r.js_renderer_names().contains(&"camoufox"));
2466        assert!(r.camoufox_in_auto);
2467    }
2468
2469    /// `mode = "camoufox"` pins to ONLY camoufox, and must mark it in-auto so a
2470    /// non-pinned request is not left with zero renderers.
2471    #[cfg(feature = "camoufox")]
2472    #[test]
2473    fn camoufox_pinned_mode_uses_only_camoufox() {
2474        let cfg = camoufox_cfg(RendererMode::Camoufox, false);
2475        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2476        assert_eq!(r.js_renderer_names(), vec!["camoufox"]);
2477        assert!(r.camoufox_in_auto);
2478    }
2479
2480    #[cfg(feature = "camoufox")]
2481    #[test]
2482    fn camoufox_pinned_mode_without_base_url_errors() {
2483        let cfg = RendererConfig {
2484            mode: RendererMode::Camoufox,
2485            camoufox: Some(CamoufoxEndpoint::default()), // empty base_url
2486            ..Default::default()
2487        };
2488        let err =
2489            FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap_err();
2490        assert!(err.to_string().to_lowercase().contains("camoufox"));
2491    }
2492
2493    #[cfg(feature = "camoufox")]
2494    #[test]
2495    fn camoufox_absent_when_not_configured() {
2496        let cfg = base_cfg(RendererMode::Auto);
2497        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2498        assert!(!r.js_renderer_names().contains(&"camoufox"));
2499        assert!(!r.camoufox_in_auto);
2500    }
2501
2502    #[test]
2503    fn new_render_js_default_stored() {
2504        let cfg = RendererConfig {
2505            mode: RendererMode::None,
2506            render_js_default: Some(true),
2507            ..Default::default()
2508        };
2509        let r = FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2510        assert_eq!(r.render_js_default, Some(true));
2511    }
2512
2513    /// Mock fetcher for unit-testing dispatch logic without real CDP/HTTP.
2514    struct MockFetcher {
2515        name: &'static str,
2516        behavior: MockBehavior,
2517    }
2518
2519    #[derive(Clone)]
2520    enum MockBehavior {
2521        Ok(String),
2522        OkStatus(u16, String),
2523        Err(String),
2524    }
2525
2526    #[async_trait::async_trait]
2527    impl PageFetcher for MockFetcher {
2528        async fn fetch(
2529            &self,
2530            url: &str,
2531            _headers: &HashMap<String, String>,
2532            _wait_for_ms: Option<u64>,
2533            _deadline: crw_core::Deadline,
2534        ) -> CrwResult<FetchResult> {
2535            let (status, html) = match &self.behavior {
2536                MockBehavior::Ok(html) => (200u16, html.clone()),
2537                MockBehavior::OkStatus(s, html) => (*s, html.clone()),
2538                MockBehavior::Err(msg) => return Err(CrwError::RendererError(msg.clone())),
2539            };
2540            Ok(FetchResult {
2541                url: url.to_string(),
2542                final_url: None,
2543                status_code: status,
2544                html,
2545                content_type: Some("text/html".to_string()),
2546                raw_bytes: None,
2547                rendered_with: Some(self.name.to_string()),
2548                elapsed_ms: 0,
2549                warning: None,
2550                render_decision: None,
2551                credit_cost: 0,
2552                warnings: Vec::new(),
2553                truncated: false,
2554                deadline_exceeded: false,
2555                captured_responses: Vec::new(),
2556                screenshot: None,
2557            })
2558        }
2559
2560        fn name(&self) -> &str {
2561            self.name
2562        }
2563        fn supports_js(&self) -> bool {
2564            true
2565        }
2566        async fn is_available(&self) -> bool {
2567            true
2568        }
2569    }
2570
2571    fn rich_html(marker: &str) -> String {
2572        format!(
2573            "<html><body><article>{}{}</article></body></html>",
2574            marker,
2575            "x".repeat(200)
2576        )
2577    }
2578
2579    fn make_renderer_with_mocks(mocks: Vec<Arc<dyn PageFetcher>>) -> FallbackRenderer {
2580        // Build a real HTTP fetcher (won't be hit when render_js=Some(true)).
2581        let cfg = base_cfg(RendererMode::None);
2582        let mut r =
2583            FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap();
2584        r.js_renderers = mocks;
2585        r
2586    }
2587
2588    #[tokio::test]
2589    async fn proxy_active_lightpanda_only_fails_closed() {
2590        // When a proxy is active but the only JS renderer is lightpanda (which
2591        // cannot proxy), fetch_with_js must hard-error, never egress direct.
2592        let lp = Arc::new(MockFetcher {
2593            name: "lightpanda",
2594            behavior: MockBehavior::Ok(rich_html("LP-")),
2595        }) as Arc<dyn PageFetcher>;
2596        let r = make_renderer_with_mocks(vec![lp]);
2597        let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2598        // Call fetch_with_js directly to isolate the lightpanda guard from the
2599        // HTTP pre-fetch (which would otherwise fail against the fake proxy).
2600        let res = REQUEST_PROXY
2601            .scope(Some(entry), async {
2602                r.fetch_with_js(
2603                    "https://example.com",
2604                    &HashMap::new(),
2605                    None,
2606                    None,
2607                    crw_core::Deadline::from_request_ms(5000),
2608                )
2609                .await
2610            })
2611            .await;
2612        assert!(
2613            res.is_err(),
2614            "lightpanda-only + proxy active must fail closed, got {res:?}"
2615        );
2616    }
2617
2618    #[tokio::test]
2619    async fn proxy_active_prefers_chrome_over_lightpanda() {
2620        // With a proxy active, lightpanda is skipped and chrome (proxy-capable)
2621        // serves the request.
2622        let lp = Arc::new(MockFetcher {
2623            name: "lightpanda",
2624            behavior: MockBehavior::Ok(rich_html("LP-")),
2625        }) as Arc<dyn PageFetcher>;
2626        let chrome = Arc::new(MockFetcher {
2627            name: "chrome",
2628            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2629        }) as Arc<dyn PageFetcher>;
2630        let r = make_renderer_with_mocks(vec![lp, chrome]);
2631        let entry = Arc::new(crw_core::ProxyEntry::parse("http://p:8080").unwrap());
2632        let res = REQUEST_PROXY
2633            .scope(Some(entry), async {
2634                r.fetch_with_js(
2635                    "https://example.com",
2636                    &HashMap::new(),
2637                    None,
2638                    None,
2639                    crw_core::Deadline::from_request_ms(5000),
2640                )
2641                .await
2642            })
2643            .await
2644            .unwrap();
2645        assert_eq!(res.rendered_with.as_deref(), Some("chrome"));
2646    }
2647
2648    #[tokio::test]
2649    async fn fetch_with_pinned_renderer_filters_pool() {
2650        let lp = Arc::new(MockFetcher {
2651            name: "lightpanda",
2652            behavior: MockBehavior::Ok(rich_html("LP-")),
2653        }) as Arc<dyn PageFetcher>;
2654        let chrome = Arc::new(MockFetcher {
2655            name: "chrome",
2656            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2657        }) as Arc<dyn PageFetcher>;
2658        let r = make_renderer_with_mocks(vec![lp, chrome]);
2659
2660        let result = r
2661            .fetch(
2662                "https://example.com",
2663                &HashMap::new(),
2664                Some(true),
2665                None,
2666                Some("chrome"),
2667                tdl(),
2668            )
2669            .await
2670            .unwrap();
2671        assert!(result.html.contains("CHROME-"), "expected chrome output");
2672        assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2673    }
2674
2675    #[tokio::test]
2676    async fn fetch_with_pinned_renderer_unknown_returns_error() {
2677        let chrome = Arc::new(MockFetcher {
2678            name: "chrome",
2679            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2680        }) as Arc<dyn PageFetcher>;
2681        let r = make_renderer_with_mocks(vec![chrome]);
2682
2683        let err = r
2684            .fetch(
2685                "https://example.com",
2686                &HashMap::new(),
2687                Some(true),
2688                None,
2689                Some("lightpanda"),
2690                tdl(),
2691            )
2692            .await
2693            .unwrap_err();
2694        let msg = err.to_string();
2695        assert!(
2696            msg.contains("lightpanda") && msg.contains("chrome"),
2697            "expected error to name pinned + available: {msg}"
2698        );
2699    }
2700
2701    #[tokio::test]
2702    async fn fetch_with_renderer_auto_uses_full_chain() {
2703        let lp = Arc::new(MockFetcher {
2704            name: "lightpanda",
2705            behavior: MockBehavior::Ok(rich_html("LP-")),
2706        }) as Arc<dyn PageFetcher>;
2707        let chrome = Arc::new(MockFetcher {
2708            name: "chrome",
2709            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2710        }) as Arc<dyn PageFetcher>;
2711        let r = make_renderer_with_mocks(vec![lp, chrome]);
2712
2713        let result = r
2714            .fetch(
2715                "https://example.com",
2716                &HashMap::new(),
2717                Some(true),
2718                None,
2719                Some("auto"),
2720                tdl(),
2721            )
2722            .await
2723            .unwrap();
2724        // First renderer in the chain wins when both succeed.
2725        assert!(result.html.contains("LP-"), "expected lightpanda first");
2726    }
2727
2728    #[tokio::test]
2729    async fn failover_skips_renderer_that_returns_failed_render() {
2730        // LightPanda returns HTML with a Next.js error boundary marker.
2731        // The chain must skip it and use Chrome's healthy result.
2732        let bad_lp_html = format!(
2733            "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2734            "x".repeat(200)
2735        );
2736        let lp = Arc::new(MockFetcher {
2737            name: "lightpanda",
2738            behavior: MockBehavior::Ok(bad_lp_html),
2739        }) as Arc<dyn PageFetcher>;
2740        let chrome = Arc::new(MockFetcher {
2741            name: "chrome",
2742            behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2743        }) as Arc<dyn PageFetcher>;
2744        let r = make_renderer_with_mocks(vec![lp, chrome]);
2745
2746        let result = r
2747            .fetch(
2748                "https://example.com",
2749                &HashMap::new(),
2750                Some(true),
2751                None,
2752                None,
2753                tdl(),
2754            )
2755            .await
2756            .unwrap();
2757        assert!(result.html.contains("CHROME-OK"));
2758        assert_eq!(result.rendered_with.as_deref(), Some("chrome"));
2759    }
2760
2761    #[tokio::test]
2762    async fn failover_surfaces_warning_when_only_failed_render_available() {
2763        // Only LightPanda is configured and it returns a failed render. The
2764        // call must succeed (best-effort thin_result fallback) but the warning
2765        // must name the failure so callers can surface it to the user.
2766        let bad_lp_html = format!(
2767            "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2768            "x".repeat(200)
2769        );
2770        let lp = Arc::new(MockFetcher {
2771            name: "lightpanda",
2772            behavior: MockBehavior::Ok(bad_lp_html),
2773        }) as Arc<dyn PageFetcher>;
2774        let r = make_renderer_with_mocks(vec![lp]);
2775
2776        let result = r
2777            .fetch(
2778                "https://example.com",
2779                &HashMap::new(),
2780                Some(true),
2781                None,
2782                None,
2783                tdl(),
2784            )
2785            .await
2786            .unwrap();
2787        let warning = result.warning.expect("expected warning to be set");
2788        assert!(
2789            warning.contains("lightpanda") && warning.contains("nextjs_client_error"),
2790            "warning should name renderer + reason: {warning}"
2791        );
2792    }
2793
2794    #[tokio::test]
2795    async fn failover_concats_warnings_across_two_failed_renderers() {
2796        // Both renderers return failed-render HTML. The fallback `thin_result`
2797        // should carry warnings from BOTH attempts so debugging captures the
2798        // full chain, not just the first failure.
2799        let bad_lp_html = format!(
2800            "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2801            "x".repeat(200)
2802        );
2803        let bad_chrome_html = format!(
2804            "<html><body><div id=\"__next_error__\">{}</div></body></html>",
2805            "y".repeat(200)
2806        );
2807        let lp = Arc::new(MockFetcher {
2808            name: "lightpanda",
2809            behavior: MockBehavior::Ok(bad_lp_html),
2810        }) as Arc<dyn PageFetcher>;
2811        let chrome = Arc::new(MockFetcher {
2812            name: "chrome",
2813            behavior: MockBehavior::Ok(bad_chrome_html),
2814        }) as Arc<dyn PageFetcher>;
2815        let r = make_renderer_with_mocks(vec![lp, chrome]);
2816
2817        let result = r
2818            .fetch(
2819                "https://example.com",
2820                &HashMap::new(),
2821                Some(true),
2822                None,
2823                None,
2824                tdl(),
2825            )
2826            .await
2827            .unwrap();
2828        let warning = result.warning.expect("expected warning to be set");
2829        assert!(
2830            warning.contains("lightpanda") && warning.contains("chrome"),
2831            "warning should mention both renderers: {warning}"
2832        );
2833    }
2834
2835    #[tokio::test]
2836    async fn fetch_pinned_renderer_failure_propagates() {
2837        let chrome = Arc::new(MockFetcher {
2838            name: "chrome",
2839            behavior: MockBehavior::Err("boom".into()),
2840        }) as Arc<dyn PageFetcher>;
2841        let r = make_renderer_with_mocks(vec![chrome]);
2842
2843        let err = r
2844            .fetch(
2845                "https://example.com",
2846                &HashMap::new(),
2847                Some(true),
2848                None,
2849                Some("chrome"),
2850                tdl(),
2851            )
2852            .await
2853            .unwrap_err();
2854        assert!(err.to_string().contains("boom"));
2855    }
2856
2857    #[tokio::test]
2858    async fn auto_promoted_host_tries_chrome_first() {
2859        // Pre-promote example.com via the preference learner so the loop
2860        // sorts chrome ahead of lightpanda even though lightpanda was
2861        // declared first. The first renderer in the executed order wins.
2862        let lp = Arc::new(MockFetcher {
2863            name: "lightpanda",
2864            behavior: MockBehavior::Ok(rich_html("LP-")),
2865        }) as Arc<dyn PageFetcher>;
2866        let chrome = Arc::new(MockFetcher {
2867            name: "chrome",
2868            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2869        }) as Arc<dyn PageFetcher>;
2870        let r = make_renderer_with_mocks(vec![lp, chrome]);
2871
2872        // Force-promote "example.com" by reaching the failure threshold.
2873        for _ in 0..3 {
2874            r.preferences
2875                .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
2876                .await;
2877        }
2878
2879        let result = r
2880            .fetch(
2881                "https://example.com",
2882                &HashMap::new(),
2883                Some(true),
2884                None,
2885                None,
2886                tdl(),
2887            )
2888            .await
2889            .unwrap();
2890        assert!(
2891            result.html.contains("CHROME-"),
2892            "promoted host should hit chrome first, got: {}",
2893            &result.html[..80.min(result.html.len())]
2894        );
2895        assert_eq!(result.credit_cost, 1, "every renderer costs 1 credit");
2896        assert!(matches!(
2897            result.render_decision,
2898            Some(RenderDecision::AutoPromoted {
2899                chosen: RendererKind::Chrome,
2900                ..
2901            })
2902        ));
2903    }
2904
2905    #[tokio::test]
2906    async fn breaker_skipped_renderer_falls_through_to_next() {
2907        // Trip the per-host breaker for lightpanda, then verify the loop
2908        // skips it and uses chrome — without ever calling lightpanda.fetch.
2909        let lp = Arc::new(MockFetcher {
2910            name: "lightpanda",
2911            behavior: MockBehavior::Err("would fire if reached".into()),
2912        }) as Arc<dyn PageFetcher>;
2913        let chrome = Arc::new(MockFetcher {
2914            name: "chrome",
2915            behavior: MockBehavior::Ok(rich_html("CHROME-OK")),
2916        }) as Arc<dyn PageFetcher>;
2917        let mut r = make_renderer_with_mocks(vec![lp, chrome]);
2918
2919        // Use a custom breaker config: long cooldown so the breaker can't
2920        // transition to half-open under parallel test load (the default
2921        // 5s cooldown was racing against scheduler latency on workspace runs).
2922        // Threshold/window stay tuned to default: 80 consecutive failures
2923        // satisfies min_calls=50 and far exceeds failure_rate=0.80.
2924        let breaker_cfg = BreakerConfig {
2925            base_cooldown: Duration::from_secs(300),
2926            max_cooldown: Duration::from_secs(300),
2927            ..BreakerConfig::default()
2928        };
2929        r.breakers = Arc::new(BreakerRegistry::new(breaker_cfg));
2930        for _ in 0..80 {
2931            r.breakers
2932                .record_result("example.com", RendererKind::Lightpanda, false)
2933                .await;
2934        }
2935
2936        let result = r
2937            .fetch(
2938                "https://example.com",
2939                &HashMap::new(),
2940                Some(true),
2941                None,
2942                None,
2943                tdl(),
2944            )
2945            .await
2946            .unwrap();
2947        assert!(result.html.contains("CHROME-OK"));
2948        assert!(matches!(
2949            result.render_decision,
2950            Some(RenderDecision::BreakerSkipped {
2951                skipped: RendererKind::Lightpanda,
2952                chosen: RendererKind::Chrome
2953            })
2954        ));
2955    }
2956
2957    #[tokio::test]
2958    async fn user_pinned_failed_render_emits_warning() {
2959        // Pin lightpanda. It returns failed-render HTML (Next.js error
2960        // boundary). Because the user hard-pinned, no failover happens.
2961        // The thin result must carry an actionable warning so callers can
2962        // surface it instead of silently returning broken markdown.
2963        let bad_html = format!(
2964            "<html><body><div id=\"__next-error-0\">{}</div></body></html>",
2965            "x".repeat(200)
2966        );
2967        let lp = Arc::new(MockFetcher {
2968            name: "lightpanda",
2969            behavior: MockBehavior::Ok(bad_html),
2970        }) as Arc<dyn PageFetcher>;
2971        let chrome = Arc::new(MockFetcher {
2972            name: "chrome",
2973            behavior: MockBehavior::Ok(rich_html("CHROME-")),
2974        }) as Arc<dyn PageFetcher>;
2975        let r = make_renderer_with_mocks(vec![lp, chrome]);
2976
2977        let result = r
2978            .fetch(
2979                "https://example.com",
2980                &HashMap::new(),
2981                Some(true),
2982                None,
2983                Some("lightpanda"),
2984                tdl(),
2985            )
2986            .await
2987            .unwrap();
2988        let pin_hint = result
2989            .warnings
2990            .iter()
2991            .find(|w| w.starts_with("Pinned renderer 'lightpanda'"));
2992        assert!(
2993            pin_hint.is_some(),
2994            "expected pin-failure hint in warnings, got: {:?}",
2995            result.warnings
2996        );
2997        let hint = pin_hint.unwrap();
2998        assert!(
2999            hint.contains("nextJsClientError"),
3000            "hint should name camelCase reason: {hint}"
3001        );
3002        assert!(
3003            hint.contains("renderer=\"chrome\""),
3004            "hint should suggest a fix: {hint}"
3005        );
3006        // chain stays single-element because user pinned → no chrome attempt
3007        assert!(matches!(
3008            result.render_decision,
3009            Some(RenderDecision::Failover { ref chain, .. }) if chain.len() == 1
3010        ));
3011    }
3012
3013    #[tokio::test]
3014    async fn user_pinned_decision_records_credit_and_kind() {
3015        let chrome = Arc::new(MockFetcher {
3016            name: "chrome",
3017            behavior: MockBehavior::Ok(rich_html("CHROME-")),
3018        }) as Arc<dyn PageFetcher>;
3019        let r = make_renderer_with_mocks(vec![chrome]);
3020        let result = r
3021            .fetch(
3022                "https://example.com",
3023                &HashMap::new(),
3024                Some(true),
3025                None,
3026                Some("chrome"),
3027                tdl(),
3028            )
3029            .await
3030            .unwrap();
3031        assert_eq!(result.credit_cost, 1);
3032        assert!(matches!(
3033            result.render_decision,
3034            Some(RenderDecision::UserPinned {
3035                renderer: RendererKind::Chrome
3036            })
3037        ));
3038    }
3039
3040    #[tokio::test]
3041    async fn js_tier_escalates_on_403_status() {
3042        // LightPanda returns 403 with content (e.g. WAF block masked as content).
3043        // The chain must escalate to Chrome instead of accepting the 403 body.
3044        let lp = Arc::new(MockFetcher {
3045            name: "lightpanda",
3046            behavior: MockBehavior::OkStatus(403, rich_html("BLOCKED-")),
3047        }) as Arc<dyn PageFetcher>;
3048        let chrome = Arc::new(MockFetcher {
3049            name: "chrome",
3050            behavior: MockBehavior::Ok(rich_html("CHROME-")),
3051        }) as Arc<dyn PageFetcher>;
3052        let r = make_renderer_with_mocks(vec![lp, chrome]);
3053
3054        let result = r
3055            .fetch(
3056                "https://example.com",
3057                &HashMap::new(),
3058                Some(true),
3059                None,
3060                Some("auto"),
3061                tdl(),
3062            )
3063            .await
3064            .unwrap();
3065        assert!(
3066            result.html.contains("CHROME-"),
3067            "expected chrome output after lightpanda 403"
3068        );
3069        assert_eq!(result.status_code, 200);
3070    }
3071
3072    #[tokio::test]
3073    async fn js_tier_escalates_on_vendor_block_with_200() {
3074        // LightPanda returns 200 with a Cloudflare challenge page. The chain
3075        // must escalate even though the status code is "successful".
3076        let cf_html = format!(
3077            "<html><head><script src=\"/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1\"></script></head><body>{}</body></html>",
3078            "x".repeat(200)
3079        );
3080        let lp = Arc::new(MockFetcher {
3081            name: "lightpanda",
3082            behavior: MockBehavior::Ok(cf_html),
3083        }) as Arc<dyn PageFetcher>;
3084        let chrome = Arc::new(MockFetcher {
3085            name: "chrome",
3086            behavior: MockBehavior::Ok(rich_html("CHROME-")),
3087        }) as Arc<dyn PageFetcher>;
3088        let r = make_renderer_with_mocks(vec![lp, chrome]);
3089
3090        let result = r
3091            .fetch(
3092                "https://example.com",
3093                &HashMap::new(),
3094                Some(true),
3095                None,
3096                Some("auto"),
3097                tdl(),
3098            )
3099            .await
3100            .unwrap();
3101        assert!(
3102            result.html.contains("CHROME-"),
3103            "expected chrome output after lightpanda vendor block"
3104        );
3105    }
3106
3107    #[tokio::test]
3108    async fn js_tier_accepts_200_clean_response() {
3109        // Regression: a clean 200 from the first renderer must still be
3110        // accepted — no false escalation triggered by the new gates.
3111        let lp = Arc::new(MockFetcher {
3112            name: "lightpanda",
3113            behavior: MockBehavior::Ok(rich_html("LP-CLEAN-")),
3114        }) as Arc<dyn PageFetcher>;
3115        let chrome = Arc::new(MockFetcher {
3116            name: "chrome",
3117            behavior: MockBehavior::Ok(rich_html("CHROME-")),
3118        }) as Arc<dyn PageFetcher>;
3119        let r = make_renderer_with_mocks(vec![lp, chrome]);
3120
3121        let result = r
3122            .fetch(
3123                "https://example.com",
3124                &HashMap::new(),
3125                Some(true),
3126                None,
3127                Some("auto"),
3128                tdl(),
3129            )
3130            .await
3131            .unwrap();
3132        assert!(result.html.contains("LP-CLEAN-"));
3133        assert_eq!(result.status_code, 200);
3134    }
3135
3136    /// A page the lightweight `detector` heuristics pass but the
3137    /// `crw_extract::antibot` classifier flags — a Reddit-class WAF block
3138    /// ("blocked by network security") served with HTTP 200.
3139    fn network_security_block_html() -> String {
3140        format!(
3141            "<html><body><article>You've been blocked by network security.{}</article></body></html>",
3142            "x".repeat(200)
3143        )
3144    }
3145
3146    #[tokio::test]
3147    async fn js_tier_escalates_to_chrome_proxy_on_antibot_block() {
3148        // lightpanda + chrome both return a 200 WAF block the detector
3149        // misses; only the residential chrome_proxy tier clears it.
3150        let lp = Arc::new(MockFetcher {
3151            name: "lightpanda",
3152            behavior: MockBehavior::Ok(network_security_block_html()),
3153        }) as Arc<dyn PageFetcher>;
3154        let chrome = Arc::new(MockFetcher {
3155            name: "chrome",
3156            behavior: MockBehavior::Ok(network_security_block_html()),
3157        }) as Arc<dyn PageFetcher>;
3158        let chrome_proxy = Arc::new(MockFetcher {
3159            name: "chrome_proxy",
3160            behavior: MockBehavior::Ok(rich_html("PROXY-")),
3161        }) as Arc<dyn PageFetcher>;
3162        let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3163
3164        let result = r
3165            .fetch(
3166                "https://example.com",
3167                &HashMap::new(),
3168                Some(true),
3169                None,
3170                Some("auto"),
3171                tdl(),
3172            )
3173            .await
3174            .unwrap();
3175        assert!(
3176            result.html.contains("PROXY-"),
3177            "expected chrome_proxy output after antibot block"
3178        );
3179        assert_eq!(
3180            result.render_decision,
3181            Some(RenderDecision::Failover {
3182                chain: vec![
3183                    RendererKind::Lightpanda,
3184                    RendererKind::Chrome,
3185                    RendererKind::ChromeProxy,
3186                ],
3187                reason: FailoverErrorKind::AntibotBlock,
3188            })
3189        );
3190    }
3191
3192    #[tokio::test]
3193    async fn antibot_block_returns_as_success_when_escalation_disabled() {
3194        // Kill switch: escalate_in_failover = false → classify() still runs
3195        // for telemetry, but the block page is returned as success with no
3196        // escalation. Proves the gate is wired correctly.
3197        let lp = Arc::new(MockFetcher {
3198            name: "lightpanda",
3199            behavior: MockBehavior::Ok(network_security_block_html()),
3200        }) as Arc<dyn PageFetcher>;
3201        let chrome = Arc::new(MockFetcher {
3202            name: "chrome",
3203            behavior: MockBehavior::Ok(rich_html("CHROME-")),
3204        }) as Arc<dyn PageFetcher>;
3205        let mut r = make_renderer_with_mocks(vec![lp, chrome]);
3206        r.antibot.escalate_in_failover = false;
3207
3208        let result = r
3209            .fetch(
3210                "https://example.com",
3211                &HashMap::new(),
3212                Some(true),
3213                None,
3214                Some("auto"),
3215                tdl(),
3216            )
3217            .await
3218            .unwrap();
3219        assert!(
3220            result.html.contains("network security"),
3221            "block page should be returned as-is when escalation is disabled"
3222        );
3223        assert_eq!(result.rendered_with.as_deref(), Some("lightpanda"));
3224    }
3225
3226    #[tokio::test]
3227    async fn promoted_host_escalates_chrome_to_chrome_proxy_not_lightpanda() {
3228        // After host promotion the preference sort must place chrome_proxy
3229        // immediately after chrome — a chrome block escalates straight to
3230        // the residential tier, never back down to lightpanda.
3231        let lp = Arc::new(MockFetcher {
3232            name: "lightpanda",
3233            behavior: MockBehavior::Ok(rich_html("LP-")),
3234        }) as Arc<dyn PageFetcher>;
3235        let chrome = Arc::new(MockFetcher {
3236            name: "chrome",
3237            behavior: MockBehavior::Ok(network_security_block_html()),
3238        }) as Arc<dyn PageFetcher>;
3239        let chrome_proxy = Arc::new(MockFetcher {
3240            name: "chrome_proxy",
3241            behavior: MockBehavior::Ok(rich_html("PROXY-")),
3242        }) as Arc<dyn PageFetcher>;
3243        let r = make_renderer_with_mocks(vec![lp, chrome, chrome_proxy]);
3244
3245        // Force-promote "example.com" so the loop sorts chrome first.
3246        for _ in 0..3 {
3247            r.preferences
3248                .record_failure("example.com", &FailoverErrorKind::NextJsClientError)
3249                .await;
3250        }
3251
3252        let result = r
3253            .fetch(
3254                "https://example.com",
3255                &HashMap::new(),
3256                Some(true),
3257                None,
3258                None,
3259                tdl(),
3260            )
3261            .await
3262            .unwrap();
3263        assert!(
3264            result.html.contains("PROXY-"),
3265            "expected chrome_proxy output"
3266        );
3267        assert_eq!(
3268            result.render_decision,
3269            Some(RenderDecision::Failover {
3270                chain: vec![RendererKind::Chrome, RendererKind::ChromeProxy],
3271                reason: FailoverErrorKind::AntibotBlock,
3272            }),
3273            "chrome must escalate straight to chrome_proxy, skipping lightpanda"
3274        );
3275    }
3276}