Skip to main content

browser_automation_cli/
retry.rs

1//! Named retry policy with budget, backoff, and jitter (rules_rust_retry_com_backoff / GAP-013).
2
3use std::time::Duration;
4
5/// Explicit retry configuration for transient network/CDP failures.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct RetryConfig {
8    /// Maximum attempts including the first try.
9    pub max_attempts: u32,
10    /// Base delay before the first retry.
11    pub base_delay: Duration,
12    /// Cap on exponential backoff delay.
13    pub max_delay: Duration,
14    /// Total wall-clock budget for all retries.
15    pub budget: Duration,
16}
17
18impl Default for RetryConfig {
19    fn default() -> Self {
20        Self {
21            max_attempts: 3,
22            base_delay: Duration::from_millis(50),
23            max_delay: Duration::from_secs(2),
24            budget: Duration::from_secs(10),
25        }
26    }
27}
28
29impl RetryConfig {
30    /// Conservative policy for CDP attach / discovery.
31    pub fn cdp() -> Self {
32        Self {
33            max_attempts: 4,
34            base_delay: Duration::from_millis(100),
35            max_delay: Duration::from_secs(3),
36            budget: Duration::from_secs(15),
37        }
38    }
39
40    /// Policy for outbound HTTP scrape.
41    pub fn http() -> Self {
42        Self {
43            max_attempts: 3,
44            base_delay: Duration::from_millis(75),
45            max_delay: Duration::from_secs(2),
46            budget: Duration::from_secs(12),
47        }
48    }
49
50    /// Policy for optional operator LLM HTTP.
51    pub fn llm() -> Self {
52        Self {
53            max_attempts: 2,
54            base_delay: Duration::from_millis(200),
55            max_delay: Duration::from_secs(4),
56            budget: Duration::from_secs(20),
57        }
58    }
59
60    /// Compute sleep duration for attempt index `0..` with full jitter.
61    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
62        let exp = self.base_delay.saturating_mul(1u32 << attempt.min(8));
63        let capped = exp.min(self.max_delay);
64        let millis = capped.as_millis() as u64;
65        if millis == 0 {
66            return Duration::ZERO;
67        }
68        let mut buf = [0u8; 8];
69        let _ = getrandom::getrandom(&mut buf);
70        let r = u64::from_le_bytes(buf) % (millis + 1);
71        Duration::from_millis(r)
72    }
73}
74
75/// Classify whether an error message looks transient (retryable).
76pub fn is_retryable_message(msg: &str) -> bool {
77    let m = msg.to_ascii_lowercase();
78    m.contains("timeout")
79        || m.contains("timed out")
80        || m.contains("connection reset")
81        || m.contains("connection refused")
82        || m.contains("temporarily")
83        || m.contains("try again")
84        || m.contains("broken pipe")
85        || m.contains("websocket")
86        || m.contains("eof")
87        || m.contains("503")
88        || m.contains("502")
89        || m.contains("429")
90}
91
92/// Run a fallible operation with the given retry policy (blocking).
93pub fn retry_blocking<T, E, F>(cfg: RetryConfig, mut f: F) -> Result<T, E>
94where
95    E: std::fmt::Display,
96    F: FnMut() -> Result<T, E>,
97{
98    let start = std::time::Instant::now();
99    let mut last_err = None;
100    for attempt in 0..cfg.max_attempts {
101        if start.elapsed() > cfg.budget {
102            break;
103        }
104        match f() {
105            Ok(v) => return Ok(v),
106            Err(e) => {
107                let retryable = is_retryable_message(&e.to_string());
108                last_err = Some(e);
109                if !retryable || attempt + 1 >= cfg.max_attempts {
110                    break;
111                }
112                std::thread::sleep(cfg.delay_for_attempt(attempt));
113            }
114        }
115    }
116    Err(last_err.expect("retry_blocking: at least one attempt"))
117}
118
119/// Async retry with the same classification rules (CDP discovery / attach).
120pub async fn retry_async<T, E, F, Fut>(cfg: RetryConfig, mut f: F) -> Result<T, E>
121where
122    E: std::fmt::Display,
123    F: FnMut() -> Fut,
124    Fut: std::future::Future<Output = Result<T, E>>,
125{
126    let start = std::time::Instant::now();
127    let mut last_err = None;
128    for attempt in 0..cfg.max_attempts {
129        if start.elapsed() > cfg.budget {
130            break;
131        }
132        match f().await {
133            Ok(v) => return Ok(v),
134            Err(e) => {
135                let retryable = is_retryable_message(&e.to_string());
136                last_err = Some(e);
137                if !retryable || attempt + 1 >= cfg.max_attempts {
138                    break;
139                }
140                tokio::time::sleep(cfg.delay_for_attempt(attempt)).await;
141            }
142        }
143    }
144    Err(last_err.expect("retry_async: at least one attempt"))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::sync::atomic::{AtomicU32, Ordering};
151
152    #[test]
153    fn succeeds_after_transient_failures() {
154        let n = AtomicU32::new(0);
155        let cfg = RetryConfig {
156            max_attempts: 5,
157            base_delay: Duration::from_millis(1),
158            max_delay: Duration::from_millis(5),
159            budget: Duration::from_secs(2),
160        };
161        let r = retry_blocking(cfg, || {
162            let c = n.fetch_add(1, Ordering::SeqCst);
163            if c < 2 {
164                Err("connection reset by peer")
165            } else {
166                Ok(42)
167            }
168        });
169        assert_eq!(r.unwrap(), 42);
170        assert_eq!(n.load(Ordering::SeqCst), 3);
171    }
172
173    #[test]
174    fn permanent_errors_do_not_retry() {
175        let n = AtomicU32::new(0);
176        let cfg = RetryConfig {
177            max_attempts: 5,
178            base_delay: Duration::from_millis(1),
179            max_delay: Duration::from_millis(5),
180            budget: Duration::from_secs(2),
181        };
182        let r: Result<(), &str> = retry_blocking(cfg, || {
183            n.fetch_add(1, Ordering::SeqCst);
184            Err("invalid argument permanent")
185        });
186        assert!(r.is_err());
187        assert_eq!(n.load(Ordering::SeqCst), 1);
188    }
189
190    #[test]
191    fn classifies_retryable() {
192        assert!(is_retryable_message("HTTP 503"));
193        assert!(!is_retryable_message("parse error in robots"));
194    }
195}