1use std::time::Duration;
9
10use tracing::warn;
11
12use crate::error::Error;
13
14#[derive(Debug, Clone)]
32pub struct RetryPolicy {
33 pub(crate) max_attempts: u32,
34 pub(crate) initial_backoff: Duration,
35 pub(crate) max_backoff: Duration,
36 pub(crate) backoff_strategy: BackoffStrategy,
37 pub(crate) retry_on_timeout: bool,
38 pub(crate) retry_exit_codes: Vec<i32>,
39}
40
41#[derive(Debug, Clone, Copy)]
43pub enum BackoffStrategy {
44 Fixed,
46 Exponential,
48}
49
50impl Default for RetryPolicy {
51 fn default() -> Self {
52 Self {
53 max_attempts: 3,
54 initial_backoff: Duration::from_secs(1),
55 max_backoff: Duration::from_secs(30),
56 backoff_strategy: BackoffStrategy::Fixed,
57 retry_on_timeout: true,
58 retry_exit_codes: Vec::new(),
59 }
60 }
61}
62
63impl RetryPolicy {
64 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 #[must_use]
74 pub fn max_attempts(mut self, n: u32) -> Self {
75 self.max_attempts = n;
76 self
77 }
78
79 #[must_use]
81 pub fn initial_backoff(mut self, duration: Duration) -> Self {
82 self.initial_backoff = duration;
83 self
84 }
85
86 #[must_use]
88 pub fn max_backoff(mut self, duration: Duration) -> Self {
89 self.max_backoff = duration;
90 self
91 }
92
93 #[must_use]
95 pub fn fixed(mut self) -> Self {
96 self.backoff_strategy = BackoffStrategy::Fixed;
97 self
98 }
99
100 #[must_use]
102 pub fn exponential(mut self) -> Self {
103 self.backoff_strategy = BackoffStrategy::Exponential;
104 self
105 }
106
107 #[must_use]
109 pub fn retry_on_timeout(mut self, retry: bool) -> Self {
110 self.retry_on_timeout = retry;
111 self
112 }
113
114 #[must_use]
116 pub fn retry_on_exit_codes(mut self, codes: impl IntoIterator<Item = i32>) -> Self {
117 self.retry_exit_codes = codes.into_iter().collect();
118 self
119 }
120
121 pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
123 let delay = match self.backoff_strategy {
124 BackoffStrategy::Fixed => self.initial_backoff,
125 BackoffStrategy::Exponential => self
126 .initial_backoff
127 .saturating_mul(2u32.saturating_pow(attempt)),
128 };
129 delay.min(self.max_backoff)
130 }
131
132 pub(crate) fn should_retry(&self, error: &Error) -> bool {
134 match error {
135 Error::Timeout { .. } => self.retry_on_timeout,
136 Error::CommandFailed { exit_code, .. } => self.retry_exit_codes.contains(exit_code),
137 _ => false,
138 }
139 }
140}
141
142#[cfg(feature = "async")]
144pub(crate) async fn with_retry<F, Fut, T>(
145 policy: &RetryPolicy,
146 mut operation: F,
147) -> crate::error::Result<T>
148where
149 F: FnMut() -> Fut,
150 Fut: std::future::Future<Output = crate::error::Result<T>>,
151{
152 let mut last_error = None;
153
154 for attempt in 0..policy.max_attempts {
155 match operation().await {
156 Ok(result) => return Ok(result),
157 Err(e) => {
158 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
159 let delay = policy.delay_for_attempt(attempt);
160 warn!(
161 attempt = attempt + 1,
162 max_attempts = policy.max_attempts,
163 delay_ms = delay.as_millis() as u64,
164 error = %e,
165 "retrying after transient error"
166 );
167 tokio::time::sleep(delay).await;
168 last_error = Some(e);
169 } else {
170 return Err(e);
171 }
172 }
173 }
174 }
175
176 Err(last_error.expect("at least one attempt was made"))
177}
178
179#[cfg(feature = "sync")]
182pub(crate) fn with_retry_sync<F, T>(
183 policy: &RetryPolicy,
184 mut operation: F,
185) -> crate::error::Result<T>
186where
187 F: FnMut() -> crate::error::Result<T>,
188{
189 let mut last_error = None;
190
191 for attempt in 0..policy.max_attempts {
192 match operation() {
193 Ok(result) => return Ok(result),
194 Err(e) => {
195 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
196 let delay = policy.delay_for_attempt(attempt);
197 warn!(
198 attempt = attempt + 1,
199 max_attempts = policy.max_attempts,
200 delay_ms = delay.as_millis() as u64,
201 error = %e,
202 "retrying after transient error"
203 );
204 std::thread::sleep(delay);
205 last_error = Some(e);
206 } else {
207 return Err(e);
208 }
209 }
210 }
211 }
212
213 Err(last_error.expect("at least one attempt was made"))
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_default_policy() {
222 let policy = RetryPolicy::new();
223 assert_eq!(policy.max_attempts, 3);
224 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
225 assert!(policy.retry_on_timeout);
226 assert!(policy.retry_exit_codes.is_empty());
227 }
228
229 #[test]
230 fn test_builder() {
231 let policy = RetryPolicy::new()
232 .max_attempts(5)
233 .initial_backoff(Duration::from_millis(500))
234 .exponential()
235 .retry_on_timeout(false)
236 .retry_on_exit_codes([1, 2, 3]);
237
238 assert_eq!(policy.max_attempts, 5);
239 assert_eq!(policy.initial_backoff, Duration::from_millis(500));
240 assert!(!policy.retry_on_timeout);
241 assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
242 }
243
244 #[test]
245 fn test_fixed_delay() {
246 let policy = RetryPolicy::new()
247 .initial_backoff(Duration::from_secs(2))
248 .fixed();
249
250 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
251 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
252 assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
253 }
254
255 #[test]
256 fn test_exponential_delay() {
257 let policy = RetryPolicy::new()
258 .initial_backoff(Duration::from_secs(1))
259 .max_backoff(Duration::from_secs(30))
260 .exponential();
261
262 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
263 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
264 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
265 assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
266 assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
268 }
269
270 #[test]
271 fn test_should_retry_timeout() {
272 let policy = RetryPolicy::new().retry_on_timeout(true);
273 let error = Error::Timeout {
274 timeout_seconds: 60,
275 };
276 assert!(policy.should_retry(&error));
277
278 let policy = RetryPolicy::new().retry_on_timeout(false);
279 assert!(!policy.should_retry(&error));
280 }
281
282 #[test]
283 fn test_should_retry_exit_code() {
284 let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
285
286 let retryable = Error::CommandFailed {
287 command: "test".into(),
288 exit_code: 1,
289 stdout: String::new(),
290 stderr: String::new(),
291 working_dir: None,
292 };
293 assert!(policy.should_retry(&retryable));
294
295 let not_retryable = Error::CommandFailed {
296 command: "test".into(),
297 exit_code: 99,
298 stdout: String::new(),
299 stderr: String::new(),
300 working_dir: None,
301 };
302 assert!(!policy.should_retry(¬_retryable));
303 }
304
305 #[test]
306 fn test_should_not_retry_other_errors() {
307 let policy = RetryPolicy::new()
308 .retry_on_timeout(true)
309 .retry_on_exit_codes([1]);
310
311 let error = Error::NotFound;
312 assert!(!policy.should_retry(&error));
313 }
314
315 #[cfg(feature = "async")]
316 #[tokio::test]
317 async fn test_with_retry_succeeds_first_try() {
318 let policy = RetryPolicy::new().max_attempts(3);
319 let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
320 assert_eq!(result.unwrap(), 42);
321 }
322
323 #[cfg(feature = "async")]
324 #[tokio::test]
325 async fn test_with_retry_succeeds_after_failures() {
326 let policy = RetryPolicy::new()
327 .max_attempts(3)
328 .initial_backoff(Duration::from_millis(1))
329 .retry_on_timeout(true);
330
331 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
332 let attempt_clone = attempt.clone();
333
334 let result = with_retry(&policy, || {
335 let attempt = attempt_clone.clone();
336 async move {
337 let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
338 if n < 2 {
339 Err(Error::Timeout {
340 timeout_seconds: 60,
341 })
342 } else {
343 Ok(42)
344 }
345 }
346 })
347 .await;
348
349 assert_eq!(result.unwrap(), 42);
350 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
351 }
352
353 #[cfg(feature = "async")]
354 #[tokio::test]
355 async fn test_with_retry_exhausts_attempts() {
356 let policy = RetryPolicy::new()
357 .max_attempts(2)
358 .initial_backoff(Duration::from_millis(1))
359 .retry_on_timeout(true);
360
361 let result: crate::error::Result<()> = with_retry(&policy, || async {
362 Err(Error::Timeout {
363 timeout_seconds: 60,
364 })
365 })
366 .await;
367
368 assert!(matches!(result, Err(Error::Timeout { .. })));
369 }
370
371 #[cfg(feature = "async")]
372 #[tokio::test]
373 async fn test_with_retry_no_retry_on_non_retryable() {
374 let policy = RetryPolicy::new()
375 .max_attempts(3)
376 .initial_backoff(Duration::from_millis(1))
377 .retry_on_timeout(false);
378
379 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
380 let attempt_clone = attempt.clone();
381
382 let result: crate::error::Result<()> = with_retry(&policy, || {
383 let attempt = attempt_clone.clone();
384 async move {
385 attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
386 Err(Error::Timeout {
387 timeout_seconds: 60,
388 })
389 }
390 })
391 .await;
392
393 assert!(result.is_err());
394 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
396 }
397
398 #[cfg(feature = "sync")]
399 #[test]
400 fn test_with_retry_sync_succeeds_first_try() {
401 let policy = RetryPolicy::new().max_attempts(3);
402 let result = with_retry_sync(&policy, || Ok::<_, Error>(42));
403 assert_eq!(result.unwrap(), 42);
404 }
405
406 #[cfg(feature = "sync")]
407 #[test]
408 fn test_with_retry_sync_succeeds_after_failures() {
409 use std::sync::atomic::{AtomicU32, Ordering};
410
411 let policy = RetryPolicy::new()
412 .max_attempts(3)
413 .initial_backoff(Duration::from_millis(1))
414 .retry_on_timeout(true);
415
416 let attempt = AtomicU32::new(0);
417 let result = with_retry_sync(&policy, || {
418 let n = attempt.fetch_add(1, Ordering::SeqCst);
419 if n < 2 {
420 Err(Error::Timeout {
421 timeout_seconds: 60,
422 })
423 } else {
424 Ok(42)
425 }
426 });
427
428 assert_eq!(result.unwrap(), 42);
429 assert_eq!(attempt.load(Ordering::SeqCst), 3);
430 }
431
432 #[cfg(feature = "sync")]
433 #[test]
434 fn test_with_retry_sync_exhausts_attempts() {
435 let policy = RetryPolicy::new()
436 .max_attempts(2)
437 .initial_backoff(Duration::from_millis(1))
438 .retry_on_timeout(true);
439
440 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
441 Err(Error::Timeout {
442 timeout_seconds: 60,
443 })
444 });
445
446 assert!(matches!(result, Err(Error::Timeout { .. })));
447 }
448
449 #[cfg(feature = "sync")]
450 #[test]
451 fn test_with_retry_sync_no_retry_on_non_retryable() {
452 use std::sync::atomic::{AtomicU32, Ordering};
453
454 let policy = RetryPolicy::new()
455 .max_attempts(3)
456 .initial_backoff(Duration::from_millis(1))
457 .retry_on_timeout(false);
458
459 let attempt = AtomicU32::new(0);
460 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
461 attempt.fetch_add(1, Ordering::SeqCst);
462 Err(Error::Timeout {
463 timeout_seconds: 60,
464 })
465 });
466
467 assert!(result.is_err());
468 assert_eq!(attempt.load(Ordering::SeqCst), 1);
469 }
470}