1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ErrorClass {
54 Transient,
55 Blocked,
56 BackendDown,
57 Auth,
58 RateLimit,
59}
60
61impl ErrorClass {
62 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
74fn build_error_classifier() -> KeywordClassifier<ErrorClass> {
78 KeywordClassifier::new(&[
79 (
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 (&["401", "402", "unauthorized"], ErrorClass::Auth),
115 (
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 (
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 (
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
154fn build_disconnection_classifier() -> KeywordClassifier<bool> {
156 KeywordClassifier::new(&[
157 (&["err_blocked_by_client"], false),
159 (
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
181pub struct RetryOptions {
187 pub max_retries: u32,
188 pub transport_opts: TransportOptions,
189 pub emitter: SpiderEventEmitter,
190 pub max_stealth_level: Option<u32>,
192 pub retry_timeout_ms: Option<u64>,
194 pub command_timeout_ms: Option<u64>,
196}
197
198pub 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
206struct TryResult<T> {
211 success: bool,
212 value: Option<T>,
213 total_attempts: u32,
214 tried_action: bool,
215 last_error: Option<SpiderError>,
216}
217
218pub 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 down_backends: HashSet<String>,
236 timeout_count: AtomicU32,
238 error_classifier: KeywordClassifier<ErrorClass>,
240 disconnection_classifier: KeywordClassifier<bool>,
241}
242
243impl RetryEngine {
244 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 pub fn stealth_level(&self) -> u32 {
267 self.current_stealth_level.load(Ordering::Relaxed)
268 }
269
270 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 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; 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 for si in 0..stealth_levels.len() {
306 if total_attempts >= budget {
307 break;
308 }
309 if phase1_timeouts >= 1 {
312 had_timeout = true;
313 break;
314 }
315
316 let stealth = stealth_levels[si];
317
318 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 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 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 if self.is_disconnection_error(err) {
382 consecutive_disconnects += 1;
383 } else {
384 consecutive_disconnects = 0;
385 }
386 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 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 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 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 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 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 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); tokio::time::sleep(tokio::time::Duration::from_millis(base_ms + jitter))
579 .await;
580 continue;
581 }
582
583 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 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 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 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 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; }
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 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 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 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 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 if msg.contains("429") {
755 return ErrorClass::RateLimit;
756 }
757 ErrorClass::Transient
758 }
759 }
760 }
761
762 fn is_disconnection_error(&self, err: &SpiderError) -> bool {
764 let msg = err.to_string();
765 match err {
766 SpiderError::Navigation(_) => {
769 self.disconnection_classifier.classify(&msg) != Some(&false)
770 }
771 _ => self.disconnection_classifier.classify(&msg) == Some(&true),
772 }
773 }
774
775 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 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 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 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 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 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 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 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}