Skip to main content

spider_browser/retry/
retry_engine.rs

1//! Smart retry engine with stealth-first escalation.
2//!
3//! **Strategy: escalate proxy quality (stealth) FAST, try alternative engines LAST.**
4//!
5//! - **Phase 1 (Stealth escalation)**: For each stealth level `[0 -> max]`,
6//!   try PRIMARY browsers `[chrome-h, chrome-new]` with transient retries.
7//!   If blocked -> skip remaining primary browsers, escalate stealth immediately.
8//!
9//! - **Phase 2 (Extended rotation)**: Only at max stealth, if still blocked,
10//!   try EXTENDED browsers `[firefox, lightpanda, servo]` for different engine
11//!   fingerprints + best proxies.
12//!
13//! ```text
14//! Phase 1 -- stealth escalation across primary browsers:
15//!   for each stealth level (initial -> maxStealthLevel):
16//!     for each PRIMARY browser [chrome-h, chrome-new]:
17//!       attempt action
18//!       if transient   -> reconnect same browser, retry up to 2x
19//!       if blocked     -> skip remaining primary, escalate stealth immediately
20//!       if backend_down-> mark down, next browser
21//!       if auth        -> throw immediately
22//!       if rate_limit  -> wait, retry same browser
23//!
24//! Phase 2 -- extended rotation at max stealth only (if blocked):
25//!   for each EXTENDED browser [firefox, lightpanda, servo]:
26//!     single attempt (no transient retries)
27//! ```
28
29use std::collections::HashSet;
30use std::future::Future;
31use std::sync::atomic::{AtomicU32, Ordering};
32use std::sync::Arc;
33
34use serde_json::json;
35use tracing::{info, warn};
36use url::Url;
37
38use crate::errors::SpiderError;
39use crate::events::SpiderEventEmitter;
40use crate::protocol::protocol_adapter::{ProtocolAdapter, ProtocolAdapterOptions};
41use crate::protocol::transport::{Transport, TransportOptions};
42
43use super::browser_selector::{BrowserSelector, EXTENDED_ROTATION, PRIMARY_ROTATION};
44use super::failure_tracker::FailureTracker;
45use super::keyword_classifier::KeywordClassifier;
46
47// ---------------------------------------------------------------------------
48// Error classification
49// ---------------------------------------------------------------------------
50
51/// Error classification for retry decisions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ErrorClass {
54    Transient,
55    Blocked,
56    BackendDown,
57    Auth,
58    RateLimit,
59}
60
61impl ErrorClass {
62    /// Canonical lowercase name, used when tagging failures in the tracker.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            ErrorClass::Transient => "transient",
66            ErrorClass::Blocked => "blocked",
67            ErrorClass::BackendDown => "backend_down",
68            ErrorClass::Auth => "auth",
69            ErrorClass::RateLimit => "rate_limit",
70        }
71    }
72}
73
74/// Build the error classifier (Aho-Corasick, O(n) single-pass).
75///
76/// Rules are priority-ordered -- first match wins.
77fn build_error_classifier() -> KeywordClassifier<ErrorClass> {
78    KeywordClassifier::new(&[
79        // Blocked -- checked first (most common heuristic case)
80        (
81            &[
82                "bot detect",
83                "are you a robot",
84                "bot or not",
85                "blocked",
86                "403",
87                "captcha",
88                "network security",
89                "human verification",
90                "verify you are human",
91                "show us your human side",
92                "can't tell if you're a human",
93                "checking your browser",
94                "bot protection",
95                "automated access",
96                "suspected automated",
97                "prove you're not a robot",
98                "pardon our interruption",
99                "powered and protected by",
100                "request could not be processed",
101                "access to this page has been denied",
102                "access denied",
103                "please complete the security check",
104                "enable cookies",
105                "browser check",
106                "just a moment",
107                "rate limit exceeded",
108                "too many requests",
109                "err_blocked_by_client",
110            ],
111            ErrorClass::Blocked,
112        ),
113        // Auth
114        (&["401", "402", "unauthorized"], ErrorClass::Auth),
115        // Backend down
116        (
117            &[
118                "backend unavailable",
119                "no backend",
120                "service unavailable",
121                "503",
122                "failed to create page target",
123                "unexpected server response",
124            ],
125            ErrorClass::BackendDown,
126        ),
127        // Transient (connection)
128        (
129            &[
130                "err_connection_reset",
131                "err_connection_closed",
132                "err_empty_response",
133                "err_ssl_protocol_error",
134                "err_ssl_version_or_cipher_mismatch",
135                "err_cert",
136                "timeout",
137            ],
138            ErrorClass::Transient,
139        ),
140        // Transient (WebSocket / session)
141        (
142            &[
143                "websocket is not connected",
144                "websocket closed",
145                "session with given id not found",
146                "content contamination",
147                "insufficient content",
148            ],
149            ErrorClass::Transient,
150        ),
151    ])
152}
153
154/// Build the disconnection classifier (determines whether to reconnect).
155fn build_disconnection_classifier() -> KeywordClassifier<bool> {
156    KeywordClassifier::new(&[
157        // NOT disconnections (page-level) -- checked first
158        (&["err_blocked_by_client"], false),
159        // Actual disconnections
160        (
161            &[
162                "websocket is not connected",
163                "websocket closed",
164                "session destroyed",
165                "session with given id not found",
166                "err_connection_reset",
167                "err_connection_closed",
168                "err_empty_response",
169                "socket hang up",
170                "err_aborted",
171                "content contamination",
172                "insufficient content",
173                "err_ssl_protocol_error",
174                "err_ssl_version_or_cipher_mismatch",
175            ],
176            true,
177        ),
178    ])
179}
180
181// ---------------------------------------------------------------------------
182// Configuration
183// ---------------------------------------------------------------------------
184
185/// Options for constructing a [`RetryEngine`].
186pub struct RetryOptions {
187    pub max_retries: u32,
188    pub transport_opts: TransportOptions,
189    pub emitter: SpiderEventEmitter,
190    /// Maximum stealth level to escalate to (1-3, default 3).
191    pub max_stealth_level: Option<u32>,
192    /// Timeout for retry attempts -- shorter than first try (default: 15 000 ms).
193    pub retry_timeout_ms: Option<u64>,
194    /// CDP/BiDi command timeout in ms, passed through to new adapters (default: 30 000).
195    pub command_timeout_ms: Option<u64>,
196}
197
198/// Mutable context threaded through retry attempts.
199pub struct RetryContext {
200    pub transport: Arc<Transport>,
201    pub adapter: ProtocolAdapter,
202    pub current_url: Option<String>,
203    pub on_adapter_changed: Box<dyn Fn(&ProtocolAdapter) + Send + Sync>,
204}
205
206// ---------------------------------------------------------------------------
207// Result of a single-browser try
208// ---------------------------------------------------------------------------
209
210struct TryResult<T> {
211    success: bool,
212    value: Option<T>,
213    total_attempts: u32,
214    tried_action: bool,
215    last_error: Option<SpiderError>,
216}
217
218// ---------------------------------------------------------------------------
219// RetryEngine
220// ---------------------------------------------------------------------------
221
222/// Smart retry engine with stealth-first escalation and browser rotation.
223///
224/// All internal state is lock-free: atomics for counters, `DashMap`-backed
225/// `FailureTracker`, and plain local sets for down-backend tracking (the
226/// engine is `!Sync` by design -- a single owner drives the retry loop).
227pub struct RetryEngine {
228    opts: RetryOptions,
229    selector: BrowserSelector,
230    current_stealth_level: AtomicU32,
231    max_stealth_level: u32,
232    retry_timeout_ms: u64,
233    command_timeout_ms: u64,
234    /// Browser backends that returned 503/unavailable -- persists across stealth levels.
235    down_backends: HashSet<String>,
236    /// Count of timeout errors -- used for progressive timeout escalation.
237    timeout_count: AtomicU32,
238    /// Pre-built Aho-Corasick classifiers (zero per-call cost).
239    error_classifier: KeywordClassifier<ErrorClass>,
240    disconnection_classifier: KeywordClassifier<bool>,
241}
242
243impl RetryEngine {
244    /// Create a new retry engine from the given options.
245    pub fn new(opts: RetryOptions) -> Self {
246        let stealth = opts.transport_opts.stealth_level;
247        let max_stealth = opts.max_stealth_level.unwrap_or(3);
248        let retry_timeout = opts.retry_timeout_ms.unwrap_or(15_000);
249        let cmd_timeout = opts.command_timeout_ms.unwrap_or(30_000);
250
251        Self {
252            selector: BrowserSelector::new(FailureTracker::new()),
253            current_stealth_level: AtomicU32::new(stealth),
254            max_stealth_level: max_stealth,
255            retry_timeout_ms: retry_timeout,
256            command_timeout_ms: cmd_timeout,
257            down_backends: HashSet::new(),
258            timeout_count: AtomicU32::new(0),
259            error_classifier: build_error_classifier(),
260            disconnection_classifier: build_disconnection_classifier(),
261            opts,
262        }
263    }
264
265    /// Current stealth level (0 = auto, 1-3 = explicit tiers).
266    pub fn stealth_level(&self) -> u32 {
267        self.current_stealth_level.load(Ordering::Relaxed)
268    }
269
270    /// Progressive page timeout: fail fast initially, escalate on retries.
271    ///
272    /// 1st attempt: 35 s, 2nd: 50 s, 3rd+: 65 s.
273    /// Must exceed server-side nav timeout (20 s) + retry (15 s).
274    fn progressive_timeout(&self) -> u64 {
275        match self.timeout_count.load(Ordering::Relaxed) {
276            0 => 35_000,
277            1 => 50_000,
278            _ => 65_000,
279        }
280    }
281
282    /// Execute an action with stealth-first retry across browsers and stealth levels.
283    pub async fn execute<T, F, Fut>(
284        &mut self,
285        mut make_future: F,
286        ctx: &mut RetryContext,
287    ) -> Result<T, SpiderError>
288    where
289        F: FnMut() -> Fut,
290        Fut: Future<Output = Result<T, SpiderError>>,
291    {
292        let mut last_error: Option<SpiderError> = None;
293        let mut total_attempts: u32 = 0;
294        let budget = self.opts.max_retries + 1; // total attempts allowed
295        self.down_backends.clear();
296
297        let stealth_levels = self.get_stealth_progression();
298        let initial_browser = ctx.transport.browser();
299        let mut consecutive_disconnects: u32 = 0;
300        let mut was_blocked = false;
301        let mut had_timeout = false;
302        let mut phase1_timeouts: u32 = 0;
303
304        // ---- Phase 1: Stealth escalation across primary browsers ----
305        for si in 0..stealth_levels.len() {
306            if total_attempts >= budget {
307                break;
308            }
309            // 1+ timeout on Chrome -> different Chrome variant won't help.
310            // Jump to Phase 2 (Firefox).
311            if phase1_timeouts >= 1 {
312                had_timeout = true;
313                break;
314            }
315
316            let stealth = stealth_levels[si];
317
318            // Stealth escalation (skip for first level)
319            if si > 0 {
320                let prev = stealth_levels[si - 1];
321                self.current_stealth_level.store(stealth, Ordering::Relaxed);
322                ctx.transport.set_stealth_level(stealth);
323
324                info!("retry: escalating stealth {} -> {}", prev, stealth);
325                self.opts.emitter.emit(
326                    "stealth.escalated",
327                    json!({
328                        "from": prev,
329                        "to": stealth,
330                        "reason": last_error.as_ref().map(|e| format!("{:?}", self.classify_error(e))).unwrap_or_else(|| "exhausted".into()),
331                    }),
332                );
333
334                // Give blocked browsers a fresh shot at the new stealth tier, but
335                // retain transient/disconnect failures (stealth won't fix a browser
336                // that keeps dropping the WS on this domain).
337                if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
338                    self.selector.failure_tracker().clear_class(&domain, "blocked");
339                }
340            }
341
342            let primary_browsers: Vec<&str> = if si == 0 {
343                Self::ordered_primary_browsers(&initial_browser)
344            } else {
345                PRIMARY_ROTATION.to_vec()
346            };
347
348            let mut tried_any = false;
349
350            for &browser in &primary_browsers {
351                if total_attempts >= budget {
352                    break;
353                }
354                if self.down_backends.contains(browser) {
355                    continue;
356                }
357                // 6+ consecutive WS disconnects -> server overloaded
358                if consecutive_disconnects >= 6 {
359                    warn!("retry: 6+ consecutive disconnects, server overloaded -- aborting");
360                    break;
361                }
362
363                let result = self
364                    .try_browser(&mut make_future, ctx, browser, stealth, total_attempts, budget, true)
365                    .await;
366
367                total_attempts = result.total_attempts;
368                if result.success {
369                    return Ok(result.value.unwrap());
370                }
371                if result.tried_action {
372                    tried_any = true;
373                }
374                if let Some(ref err) = result.last_error {
375                    let error_class = self.classify_error(err);
376                    was_blocked = error_class == ErrorClass::Blocked;
377                    if error_class == ErrorClass::Auth {
378                        return Err(result.last_error.unwrap());
379                    }
380                    // Track consecutive disconnects
381                    if self.is_disconnection_error(err) {
382                        consecutive_disconnects += 1;
383                    } else {
384                        consecutive_disconnects = 0;
385                    }
386                    // Timeout -> track count, break to Phase 2
387                    if matches!(err, SpiderError::Timeout(_)) {
388                        phase1_timeouts += 1;
389                        self.timeout_count.fetch_add(1, Ordering::Relaxed);
390                        had_timeout = true;
391                        break;
392                    }
393                    // Blocked -> skip remaining primary, escalate stealth
394                    if was_blocked {
395                        break;
396                    }
397                }
398                last_error = result.last_error.or(last_error);
399            }
400
401            if !tried_any {
402                warn!("retry: all browser backends unavailable, stopping");
403                break;
404            }
405        }
406
407        // ---- Phase 2: Extended rotation at max stealth ----
408        if (was_blocked || had_timeout || total_attempts > 0)
409            && total_attempts < budget
410            && !EXTENDED_ROTATION.is_empty()
411        {
412            for &browser in EXTENDED_ROTATION {
413                if total_attempts >= budget {
414                    break;
415                }
416                if self.down_backends.contains(browser) {
417                    continue;
418                }
419
420                let max_stealth =
421                    stealth_levels.last().copied().unwrap_or(self.max_stealth_level);
422                let result = self
423                    .try_browser(&mut make_future, ctx, browser, max_stealth, total_attempts, budget, false)
424                    .await;
425
426                total_attempts = result.total_attempts;
427                if result.success {
428                    return Ok(result.value.unwrap());
429                }
430                if let Some(ref err) = result.last_error {
431                    if self.classify_error(err) == ErrorClass::Auth {
432                        return Err(result.last_error.unwrap());
433                    }
434                }
435                last_error = result.last_error.or(last_error);
436            }
437        }
438
439        Err(last_error
440            .unwrap_or_else(|| SpiderError::Other("All browsers and stealth levels exhausted".into())))
441    }
442
443    /// Attempt an action on a specific browser, with optional transient retries.
444    async fn try_browser<T, F, Fut>(
445        &mut self,
446        make_future: &mut F,
447        ctx: &mut RetryContext,
448        browser: &str,
449        stealth: u32,
450        mut total_attempts: u32,
451        budget: u32,
452        allow_transient_retries: bool,
453    ) -> TryResult<T>
454    where
455        F: FnMut() -> Fut,
456        Fut: Future<Output = Result<T, SpiderError>>,
457    {
458        let last_error: Option<SpiderError> = None;
459
460        // Switch browser (skip on very first attempt -- already connected)
461        if total_attempts > 0 {
462            let prev_browser = ctx.transport.browser();
463            info!(
464                "retry: switching {} -> {} (stealth={})",
465                prev_browser, browser, stealth
466            );
467            self.opts.emitter.emit(
468                "browser.switching",
469                json!({
470                    "from": prev_browser,
471                    "to": browser,
472                    "reason": last_error.as_ref().map(|e| format!("{:?}", self.classify_error(e))).unwrap_or_else(|| "rotation".into()),
473                }),
474            );
475
476            match self.switch_browser(ctx, browser).await {
477                Ok(()) => {
478                    self.opts
479                        .emitter
480                        .emit("browser.switched", json!({ "browser": browser }));
481                }
482                Err(switch_err) => {
483                    warn!(
484                        "retry: switch to {} failed, skipping: {}",
485                        browser, switch_err
486                    );
487                    if matches!(switch_err, SpiderError::BackendUnavailable(_)) {
488                        self.down_backends.insert(browser.to_string());
489                    }
490                    return TryResult {
491                        success: false,
492                        value: None,
493                        total_attempts,
494                        tried_action: false,
495                        last_error: Some(switch_err),
496                    };
497                }
498            }
499        }
500
501        let max_transient_retries: u32 = if allow_transient_retries { 2 } else { 0 };
502        let max_disconnect_retries: u32 = if allow_transient_retries { 2 } else { 0 };
503        let mut transient_retries: u32 = 0;
504        let mut disconnect_retries: u32 = 0;
505
506        while total_attempts < budget {
507            total_attempts += 1;
508
509            match make_future().await {
510                Ok(value) => {
511                    if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
512                        self.selector
513                            .failure_tracker()
514                            .record_success(&domain, browser);
515                    }
516                    return TryResult {
517                        success: true,
518                        value: Some(value),
519                        total_attempts,
520                        tried_action: true,
521                        last_error: None,
522                    };
523                }
524                Err(err) => {
525                    let error_class = self.classify_error(&err);
526
527                    warn!(
528                        "retry: attempt {}/{} failed: {} (class={:?}, browser={}, stealth={})",
529                        total_attempts, budget, err, error_class, browser, stealth
530                    );
531
532                    self.opts.emitter.emit(
533                        "retry.attempt",
534                        json!({
535                            "attempt": total_attempts,
536                            "maxRetries": self.opts.max_retries,
537                            "error": err.to_string(),
538                        }),
539                    );
540
541                    // Auth -> bubble up immediately
542                    if error_class == ErrorClass::Auth {
543                        return TryResult {
544                            success: false,
545                            value: None,
546                            total_attempts,
547                            tried_action: true,
548                            last_error: Some(err),
549                        };
550                    }
551
552                    // Rate limit -> exponential backoff with jitter
553                    if error_class == ErrorClass::RateLimit {
554                        transient_retries += 1;
555                        if transient_retries >= 2 {
556                            if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref())
557                            {
558                                self.selector
559                                    .failure_tracker()
560                                    .record_failure_class(&domain, browser, error_class.as_str());
561                            }
562                            return TryResult {
563                                success: false,
564                                value: None,
565                                total_attempts,
566                                tried_action: true,
567                                last_error: Some(err),
568                            };
569                        }
570                        let base_ms = match &err {
571                            SpiderError::RateLimit {
572                                retry_after_ms: Some(ms),
573                                ..
574                            } => *ms,
575                            _ => 2000 * transient_retries as u64,
576                        };
577                        let jitter = (base_ms / 4).min(1000); // deterministic jitter approximation
578                        tokio::time::sleep(tokio::time::Duration::from_millis(base_ms + jitter))
579                            .await;
580                        continue;
581                    }
582
583                    // Backend down -> mark and move on
584                    if error_class == ErrorClass::BackendDown {
585                        self.down_backends.insert(browser.to_string());
586                        return TryResult {
587                            success: false,
588                            value: None,
589                            total_attempts,
590                            tried_action: true,
591                            last_error: Some(err),
592                        };
593                    }
594
595                    // Blocked -> record failure, move to next browser
596                    if error_class == ErrorClass::Blocked {
597                        if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
598                            self.selector
599                                .failure_tracker()
600                                .record_failure_class(&domain, browser, error_class.as_str());
601                        }
602                        return TryResult {
603                            success: false,
604                            value: None,
605                            total_attempts,
606                            tried_action: true,
607                            last_error: Some(err),
608                        };
609                    }
610
611                    // Timeout -> rotate immediately
612                    if matches!(&err, SpiderError::Timeout(_)) {
613                        if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
614                            self.selector
615                                .failure_tracker()
616                                .record_failure_class(&domain, browser, error_class.as_str());
617                        }
618                        return TryResult {
619                            success: false,
620                            value: None,
621                            total_attempts,
622                            tried_action: true,
623                            last_error: Some(err),
624                        };
625                    }
626
627                    // WS disconnection -> reconnect with backoff
628                    if error_class == ErrorClass::Transient && self.is_disconnection_error(&err) {
629                        if disconnect_retries < max_disconnect_retries {
630                            disconnect_retries += 1;
631                            let backoff_ms = if disconnect_retries == 1 { 1000 } else { 3000 };
632                            tokio::time::sleep(tokio::time::Duration::from_millis(backoff_ms))
633                                .await;
634
635                            // Second disconnect -> try a different engine
636                            let reconnect_browser = if disconnect_retries >= 2 {
637                                self.pick_alternate_engine(browser)
638                                    .unwrap_or_else(|| browser.to_string())
639                            } else {
640                                browser.to_string()
641                            };
642
643                            if self.switch_browser(ctx, &reconnect_browser).await.is_err() {
644                                if let Some(domain) =
645                                    Self::extract_domain(ctx.current_url.as_deref())
646                                {
647                                    self.selector
648                                        .failure_tracker()
649                                        .record_failure_class(&domain, browser, error_class.as_str());
650                                }
651                                return TryResult {
652                                    success: false,
653                                    value: None,
654                                    total_attempts,
655                                    tried_action: true,
656                                    last_error: Some(err),
657                                };
658                            }
659                            continue; // retry the action
660                        }
661                        if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
662                            self.selector
663                                .failure_tracker()
664                                .record_failure_class(&domain, browser, error_class.as_str());
665                        }
666                        return TryResult {
667                            success: false,
668                            value: None,
669                            total_attempts,
670                            tried_action: true,
671                            last_error: Some(err),
672                        };
673                    }
674
675                    // Non-disconnect transient -> retry same browser
676                    if error_class == ErrorClass::Transient
677                        && transient_retries < max_transient_retries
678                    {
679                        transient_retries += 1;
680                        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
681                        continue;
682                    }
683
684                    // Transient retry exhausted -> move to next browser
685                    if let Some(domain) = Self::extract_domain(ctx.current_url.as_deref()) {
686                        self.selector
687                            .failure_tracker()
688                            .record_failure_class(&domain, browser, error_class.as_str());
689                    }
690                    return TryResult {
691                        success: false,
692                        value: None,
693                        total_attempts,
694                        tried_action: true,
695                        last_error: Some(err),
696                    };
697                }
698            }
699        }
700
701        TryResult {
702            success: false,
703            value: None,
704            total_attempts,
705            tried_action: true,
706            last_error,
707        }
708    }
709
710    // ------------------------------------------------------------------
711    // Error classification
712    // ------------------------------------------------------------------
713
714    /// Classify an error to determine retry strategy.
715    ///
716    /// Fast path: typed error variants (`match`, no string scan).
717    /// Slow path: Aho-Corasick O(n) single-pass keyword matching.
718    fn classify_error(&self, err: &SpiderError) -> ErrorClass {
719        match err {
720            SpiderError::Auth(_) => ErrorClass::Auth,
721            SpiderError::RateLimit { .. } => ErrorClass::RateLimit,
722            SpiderError::Blocked(_) => ErrorClass::Blocked,
723            SpiderError::BackendUnavailable(_) => ErrorClass::BackendDown,
724            SpiderError::Timeout(_) => ErrorClass::Transient,
725            SpiderError::Connection { ws_code, message, .. } => {
726                if let Some(code) = ws_code {
727                    match *code {
728                        1006 | 1011 => return ErrorClass::Transient,
729                        4001 | 4002 => return ErrorClass::Auth,
730                        _ => {}
731                    }
732                }
733                // Fall through to keyword scan on the message
734                self.error_classifier
735                    .classify(message)
736                    .copied()
737                    .unwrap_or(ErrorClass::Transient)
738            }
739            SpiderError::Navigation(msg) => {
740                let cls = self.error_classifier.classify(msg).copied();
741                if cls == Some(ErrorClass::Blocked) {
742                    ErrorClass::Blocked
743                } else {
744                    ErrorClass::Transient
745                }
746            }
747            _ => {
748                let msg = err.to_string();
749                let cls = self.error_classifier.classify(&msg).copied();
750                if let Some(c) = cls {
751                    return c;
752                }
753                // Transport-level 429 fallback
754                if msg.contains("429") {
755                    return ErrorClass::RateLimit;
756                }
757                ErrorClass::Transient
758            }
759        }
760    }
761
762    /// Check if an error indicates the WebSocket/session is dead.
763    fn is_disconnection_error(&self, err: &SpiderError) -> bool {
764        let msg = err.to_string();
765        match err {
766            // NavigationError: page-level errors (false) vs actual disconnections (true).
767            // `None` (no keyword match) -> treat as disconnection for NavigationError.
768            SpiderError::Navigation(_) => {
769                self.disconnection_classifier.classify(&msg) != Some(&false)
770            }
771            _ => self.disconnection_classifier.classify(&msg) == Some(&true),
772        }
773    }
774
775    // ------------------------------------------------------------------
776    // Browser switching
777    // ------------------------------------------------------------------
778
779    /// Reconnect with a (possibly different) browser, re-navigate to the same URL.
780    async fn switch_browser(
781        &self,
782        ctx: &mut RetryContext,
783        new_browser: &str,
784    ) -> Result<(), SpiderError> {
785        ctx.adapter.destroy();
786
787        ctx.transport.reconnect(new_browser).await?;
788
789        let adapter_opts = if self.command_timeout_ms != 30_000 {
790            Some(ProtocolAdapterOptions {
791                command_timeout_ms: Some(self.command_timeout_ms),
792            })
793        } else {
794            None
795        };
796
797        // We need a sender that relays to the transport. Create a relay channel.
798        let (proto_tx, mut proto_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
799        let transport_clone = ctx.transport.clone();
800        tokio::spawn(async move {
801            while let Some(data) = proto_rx.recv().await {
802                let _ = transport_clone.send(data);
803            }
804        });
805
806        let mut new_adapter =
807            ProtocolAdapter::new(proto_tx, self.opts.emitter.clone(), new_browser, adapter_opts);
808        new_adapter.init().await?;
809
810        ctx.adapter = new_adapter;
811        (ctx.on_adapter_changed)(&ctx.adapter);
812
813        if let Some(ref url) = ctx.current_url {
814            ctx.adapter.navigate(url).await?;
815            tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
816        }
817
818        Ok(())
819    }
820
821    // ------------------------------------------------------------------
822    // Helpers
823    // ------------------------------------------------------------------
824
825    /// Stealth progression: from current level up to max.
826    ///
827    /// e.g. start=0, max=3 -> `[0, 1, 2, 3]`
828    fn get_stealth_progression(&self) -> Vec<u32> {
829        let start = self.current_stealth_level.load(Ordering::Relaxed);
830        let mut levels = vec![start];
831        let mut next = if start < 1 { 1 } else { start + 1 };
832        while next <= self.max_stealth_level {
833            levels.push(next);
834            next += 1;
835        }
836        levels
837    }
838
839    /// Order PRIMARY browsers starting from `start`, then the rest.
840    fn ordered_primary_browsers(start: &str) -> Vec<&'static str> {
841        let idx = PRIMARY_ROTATION.iter().position(|&b| b == start);
842        match idx {
843            Some(i) if i > 0 => {
844                let mut v = PRIMARY_ROTATION[i..].to_vec();
845                v.extend_from_slice(&PRIMARY_ROTATION[..i]);
846                v
847            }
848            _ => PRIMARY_ROTATION.to_vec(),
849        }
850    }
851
852    /// Pick a non-Chrome engine for disconnect recovery.
853    fn pick_alternate_engine(&self, current: &str) -> Option<String> {
854        for &browser in EXTENDED_ROTATION {
855            if browser != current && !self.down_backends.contains(browser) {
856                return Some(browser.to_string());
857            }
858        }
859        for &browser in PRIMARY_ROTATION {
860            if browser != current && !self.down_backends.contains(browser) {
861                return Some(browser.to_string());
862            }
863        }
864        None
865    }
866
867    /// Extract the hostname from a URL string.
868    fn extract_domain(url: Option<&str>) -> Option<String> {
869        url.and_then(|u| Url::parse(u).ok())
870            .and_then(|u| u.host_str().map(|h| h.to_string()))
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    #[test]
879    fn error_classifier_blocked() {
880        let c = build_error_classifier();
881        assert_eq!(c.classify("Error 403 Forbidden"), Some(&ErrorClass::Blocked));
882        assert_eq!(c.classify("CAPTCHA detected"), Some(&ErrorClass::Blocked));
883        assert_eq!(c.classify("bot detection active"), Some(&ErrorClass::Blocked));
884        assert_eq!(c.classify("Access Denied"), Some(&ErrorClass::Blocked));
885    }
886
887    #[test]
888    fn error_classifier_auth() {
889        let c = build_error_classifier();
890        assert_eq!(c.classify("HTTP 401 Unauthorized"), Some(&ErrorClass::Auth));
891    }
892
893    #[test]
894    fn error_classifier_backend_down() {
895        let c = build_error_classifier();
896        assert_eq!(
897            c.classify("503 Service Unavailable"),
898            Some(&ErrorClass::BackendDown)
899        );
900        assert_eq!(
901            c.classify("backend unavailable for chrome"),
902            Some(&ErrorClass::BackendDown)
903        );
904    }
905
906    #[test]
907    fn error_classifier_transient() {
908        let c = build_error_classifier();
909        assert_eq!(
910            c.classify("net::ERR_CONNECTION_RESET"),
911            Some(&ErrorClass::Transient)
912        );
913        assert_eq!(
914            c.classify("WebSocket closed unexpectedly"),
915            Some(&ErrorClass::Transient)
916        );
917    }
918
919    #[test]
920    fn error_classifier_no_match() {
921        let c = build_error_classifier();
922        assert_eq!(c.classify("page loaded fine"), None);
923    }
924
925    #[test]
926    fn disconnection_classifier_true() {
927        let c = build_disconnection_classifier();
928        assert_eq!(c.classify("websocket is not connected"), Some(&true));
929        assert_eq!(c.classify("socket hang up"), Some(&true));
930        assert_eq!(c.classify("err_aborted during navigation"), Some(&true));
931    }
932
933    #[test]
934    fn disconnection_classifier_false_for_page_level() {
935        let c = build_disconnection_classifier();
936        assert_eq!(
937            c.classify("net::ERR_BLOCKED_BY_CLIENT"),
938            Some(&false)
939        );
940    }
941
942    #[test]
943    fn stealth_progression() {
944        // Simulating get_stealth_progression logic
945        let cases: Vec<(u32, u32, Vec<u32>)> = vec![
946            (0, 3, vec![0, 1, 2, 3]),
947            (2, 3, vec![2, 3]),
948            (3, 3, vec![3]),
949            (0, 1, vec![0, 1]),
950        ];
951        for (start, max, expected) in cases {
952            let mut levels = vec![start];
953            let mut next = if start < 1 { 1 } else { start + 1 };
954            while next <= max {
955                levels.push(next);
956                next += 1;
957            }
958            assert_eq!(levels, expected, "start={start}, max={max}");
959        }
960    }
961
962    #[test]
963    fn ordered_primary_browsers_from_start() {
964        assert_eq!(
965            RetryEngine::ordered_primary_browsers("chrome-h"),
966            vec!["chrome-h", "chrome-new"]
967        );
968        assert_eq!(
969            RetryEngine::ordered_primary_browsers("chrome-new"),
970            vec!["chrome-new", "chrome-h"]
971        );
972        // Unknown browser -> default order
973        assert_eq!(
974            RetryEngine::ordered_primary_browsers("firefox"),
975            vec!["chrome-h", "chrome-new"]
976        );
977    }
978
979    #[test]
980    fn extract_domain_works() {
981        assert_eq!(
982            RetryEngine::extract_domain(Some("https://example.com/path")),
983            Some("example.com".to_string())
984        );
985        assert_eq!(RetryEngine::extract_domain(None), None);
986        assert_eq!(RetryEngine::extract_domain(Some("not-a-url")), None);
987    }
988}