Skip to main content

claude_wrapper/
retry.rs

1//! Retry and backoff for transient CLI failures.
2//!
3//! [`RetryPolicy`] configures how many attempts to make, the
4//! [`BackoffStrategy`] between them, and which errors count as
5//! retryable. A default policy can be attached to the
6//! [`Claude`](crate::Claude) client and overridden per command.
7
8use std::time::Duration;
9
10use tracing::warn;
11
12use crate::error::Error;
13
14/// Retry policy for transient CLI failures.
15///
16/// Configure max attempts, backoff strategy, and which errors to retry.
17///
18/// # Example
19///
20/// ```
21/// use claude_wrapper::RetryPolicy;
22/// use std::time::Duration;
23///
24/// let policy = RetryPolicy::new()
25///     .max_attempts(3)
26///     .initial_backoff(Duration::from_secs(1))
27///     .exponential()
28///     .retry_on_timeout(true)
29///     .retry_on_exit_codes([1, 2]);
30/// ```
31#[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/// Backoff strategy between retry attempts.
42#[derive(Debug, Clone, Copy)]
43pub enum BackoffStrategy {
44    /// Fixed delay between attempts.
45    Fixed,
46    /// Exponential backoff (delay doubles each attempt).
47    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    /// Create a new retry policy with default settings (3 attempts, 1s fixed backoff).
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Set the maximum number of attempts (including the initial attempt).
71    ///
72    /// A value of 1 means no retries.
73    #[must_use]
74    pub fn max_attempts(mut self, n: u32) -> Self {
75        self.max_attempts = n;
76        self
77    }
78
79    /// Set the initial delay before the first retry.
80    #[must_use]
81    pub fn initial_backoff(mut self, duration: Duration) -> Self {
82        self.initial_backoff = duration;
83        self
84    }
85
86    /// Set the maximum delay between retries (caps exponential growth).
87    #[must_use]
88    pub fn max_backoff(mut self, duration: Duration) -> Self {
89        self.max_backoff = duration;
90        self
91    }
92
93    /// Use fixed backoff (same delay between each attempt).
94    #[must_use]
95    pub fn fixed(mut self) -> Self {
96        self.backoff_strategy = BackoffStrategy::Fixed;
97        self
98    }
99
100    /// Use exponential backoff (delay doubles each attempt, capped by max_backoff).
101    #[must_use]
102    pub fn exponential(mut self) -> Self {
103        self.backoff_strategy = BackoffStrategy::Exponential;
104        self
105    }
106
107    /// Retry on timeout errors.
108    #[must_use]
109    pub fn retry_on_timeout(mut self, retry: bool) -> Self {
110        self.retry_on_timeout = retry;
111        self
112    }
113
114    /// Retry on specific non-zero exit codes.
115    #[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    /// Calculate the delay for a given attempt (0-indexed).
122    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    /// Check if the given error should be retried.
133    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/// Execute a fallible async operation with retry.
143#[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/// Execute a fallible blocking operation with retry. Sync mirror of
180/// [`with_retry`]; waits between attempts with [`std::thread::sleep`].
181#[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        // Capped at max_backoff
267        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(&not_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        // Should only attempt once since timeout is not retryable
395        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}