browser_automation_cli/
retry.rs1use std::time::Duration;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct RetryConfig {
8 pub max_attempts: u32,
10 pub base_delay: Duration,
12 pub max_delay: Duration,
14 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 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 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 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 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
75pub 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
92pub 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
119pub 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}