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