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
10#[cfg(any(feature = "async", feature = "sync"))]
11use tracing::warn;
12
13use crate::error::Error;
14
15/// Retry policy for transient CLI failures.
16///
17/// Configure max attempts, backoff strategy, and which errors to retry.
18///
19/// # Example
20///
21/// ```
22/// use claude_wrapper::RetryPolicy;
23/// use std::time::Duration;
24///
25/// let policy = RetryPolicy::new()
26///     .max_attempts(3)
27///     .initial_backoff(Duration::from_secs(1))
28///     .exponential()
29///     .retry_on_timeout(true)
30///     .retry_on_exit_codes([1, 2]);
31/// ```
32#[derive(Debug, Clone)]
33pub struct RetryPolicy {
34    pub(crate) max_attempts: u32,
35    pub(crate) initial_backoff: Duration,
36    pub(crate) max_backoff: Duration,
37    pub(crate) backoff_strategy: BackoffStrategy,
38    pub(crate) retry_on_timeout: bool,
39    pub(crate) retry_exit_codes: Vec<i32>,
40}
41
42/// Backoff strategy between retry attempts.
43#[derive(Debug, Clone, Copy)]
44pub enum BackoffStrategy {
45    /// Fixed delay between attempts.
46    Fixed,
47    /// Exponential backoff (delay doubles each attempt).
48    Exponential,
49}
50
51impl Default for RetryPolicy {
52    fn default() -> Self {
53        Self {
54            max_attempts: 3,
55            initial_backoff: Duration::from_secs(1),
56            max_backoff: Duration::from_secs(30),
57            backoff_strategy: BackoffStrategy::Fixed,
58            retry_on_timeout: true,
59            retry_exit_codes: Vec::new(),
60        }
61    }
62}
63
64impl RetryPolicy {
65    /// Create a new retry policy with default settings (3 attempts, 1s fixed backoff).
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Set the maximum number of attempts (including the initial attempt).
72    ///
73    /// A value of 1 means no retries.
74    #[must_use]
75    pub fn max_attempts(mut self, n: u32) -> Self {
76        self.max_attempts = n;
77        self
78    }
79
80    /// Set the initial delay before the first retry.
81    #[must_use]
82    pub fn initial_backoff(mut self, duration: Duration) -> Self {
83        self.initial_backoff = duration;
84        self
85    }
86
87    /// Set the maximum delay between retries (caps exponential growth).
88    #[must_use]
89    pub fn max_backoff(mut self, duration: Duration) -> Self {
90        self.max_backoff = duration;
91        self
92    }
93
94    /// Use fixed backoff (same delay between each attempt).
95    #[must_use]
96    pub fn fixed(mut self) -> Self {
97        self.backoff_strategy = BackoffStrategy::Fixed;
98        self
99    }
100
101    /// Use exponential backoff (delay doubles each attempt, capped by max_backoff).
102    #[must_use]
103    pub fn exponential(mut self) -> Self {
104        self.backoff_strategy = BackoffStrategy::Exponential;
105        self
106    }
107
108    /// Retry on timeout errors.
109    #[must_use]
110    pub fn retry_on_timeout(mut self, retry: bool) -> Self {
111        self.retry_on_timeout = retry;
112        self
113    }
114
115    /// Retry on specific non-zero exit codes.
116    #[must_use]
117    pub fn retry_on_exit_codes(mut self, codes: impl IntoIterator<Item = i32>) -> Self {
118        self.retry_exit_codes = codes.into_iter().collect();
119        self
120    }
121
122    /// Calculate the delay for a given attempt (0-indexed).
123    #[allow(dead_code)] // unused with neither `async` nor `sync` feature; unit tests cover it
124    pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
125        let delay = match self.backoff_strategy {
126            BackoffStrategy::Fixed => self.initial_backoff,
127            BackoffStrategy::Exponential => self
128                .initial_backoff
129                .saturating_mul(2u32.saturating_pow(attempt)),
130        };
131        delay.min(self.max_backoff)
132    }
133
134    /// Check if the given error should be retried.
135    #[allow(dead_code)] // unused with neither `async` nor `sync` feature; unit tests cover it
136    pub(crate) fn should_retry(&self, error: &Error) -> bool {
137        match error {
138            Error::Timeout { .. } => self.retry_on_timeout,
139            Error::CommandFailed { exit_code, .. } => self.retry_exit_codes.contains(exit_code),
140            _ => false,
141        }
142    }
143}
144
145/// Execute a fallible async operation with retry.
146#[cfg(feature = "async")]
147pub(crate) async fn with_retry<F, Fut, T>(
148    policy: &RetryPolicy,
149    mut operation: F,
150) -> crate::error::Result<T>
151where
152    F: FnMut() -> Fut,
153    Fut: std::future::Future<Output = crate::error::Result<T>>,
154{
155    // A retry span parents every attempt, so the exec spans nest under
156    // one identifiable unit of work instead of appearing as unrelated
157    // siblings. `attempts` is recorded on close: a caller reading logs
158    // wants "this took 3 tries", not three disconnected warnings.
159    let span = tracing::debug_span!(
160        "claude.retry",
161        max_attempts = policy.max_attempts,
162        attempts = tracing::field::Empty,
163    );
164    let _enter = span.enter();
165    let mut last_error = None;
166
167    for attempt in 0..policy.max_attempts {
168        span.record("attempts", attempt + 1);
169        match operation().await {
170            Ok(result) => return Ok(result),
171            Err(e) => {
172                if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
173                    let delay = policy.delay_for_attempt(attempt);
174                    warn!(
175                        attempt = attempt + 1,
176                        max_attempts = policy.max_attempts,
177                        delay_ms = delay.as_millis() as u64,
178                        timeout = matches!(e, crate::Error::Timeout { .. }),
179                        exit_code = match &e {
180                            crate::Error::CommandFailed { exit_code, .. } => Some(*exit_code),
181                            _ => None,
182                        },
183                        "retrying after transient error"
184                    );
185                    tokio::time::sleep(delay).await;
186                    last_error = Some(e);
187                } else {
188                    return Err(e);
189                }
190            }
191        }
192    }
193
194    Err(last_error.expect("at least one attempt was made"))
195}
196
197/// Execute a fallible blocking operation with retry. Sync mirror of
198/// [`with_retry`]; waits between attempts with [`std::thread::sleep`].
199#[cfg(feature = "sync")]
200pub(crate) fn with_retry_sync<F, T>(
201    policy: &RetryPolicy,
202    mut operation: F,
203) -> crate::error::Result<T>
204where
205    F: FnMut() -> crate::error::Result<T>,
206{
207    let span = tracing::debug_span!(
208        "claude.retry",
209        max_attempts = policy.max_attempts,
210        attempts = tracing::field::Empty,
211    );
212    let _enter = span.enter();
213    let mut last_error = None;
214
215    for attempt in 0..policy.max_attempts {
216        span.record("attempts", attempt + 1);
217        match operation() {
218            Ok(result) => return Ok(result),
219            Err(e) => {
220                if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
221                    let delay = policy.delay_for_attempt(attempt);
222                    warn!(
223                        attempt = attempt + 1,
224                        max_attempts = policy.max_attempts,
225                        delay_ms = delay.as_millis() as u64,
226                        timeout = matches!(e, crate::Error::Timeout { .. }),
227                        exit_code = match &e {
228                            crate::Error::CommandFailed { exit_code, .. } => Some(*exit_code),
229                            _ => None,
230                        },
231                        "retrying after transient error"
232                    );
233                    std::thread::sleep(delay);
234                    last_error = Some(e);
235                } else {
236                    return Err(e);
237                }
238            }
239        }
240    }
241
242    Err(last_error.expect("at least one attempt was made"))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_default_policy() {
251        let policy = RetryPolicy::new();
252        assert_eq!(policy.max_attempts, 3);
253        assert_eq!(policy.initial_backoff, Duration::from_secs(1));
254        assert!(policy.retry_on_timeout);
255        assert!(policy.retry_exit_codes.is_empty());
256    }
257
258    #[test]
259    fn test_builder() {
260        let policy = RetryPolicy::new()
261            .max_attempts(5)
262            .initial_backoff(Duration::from_millis(500))
263            .exponential()
264            .retry_on_timeout(false)
265            .retry_on_exit_codes([1, 2, 3]);
266
267        assert_eq!(policy.max_attempts, 5);
268        assert_eq!(policy.initial_backoff, Duration::from_millis(500));
269        assert!(!policy.retry_on_timeout);
270        assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
271    }
272
273    #[test]
274    fn test_fixed_delay() {
275        let policy = RetryPolicy::new()
276            .initial_backoff(Duration::from_secs(2))
277            .fixed();
278
279        assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
280        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
281        assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
282    }
283
284    #[test]
285    fn test_exponential_delay() {
286        let policy = RetryPolicy::new()
287            .initial_backoff(Duration::from_secs(1))
288            .max_backoff(Duration::from_secs(30))
289            .exponential();
290
291        assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
292        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
293        assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
294        assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
295        // Capped at max_backoff
296        assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
297    }
298
299    #[test]
300    fn test_should_retry_timeout() {
301        let policy = RetryPolicy::new().retry_on_timeout(true);
302        let error = Error::Timeout {
303            timeout_seconds: 60,
304        };
305        assert!(policy.should_retry(&error));
306
307        let policy = RetryPolicy::new().retry_on_timeout(false);
308        assert!(!policy.should_retry(&error));
309    }
310
311    #[test]
312    fn test_should_retry_exit_code() {
313        let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
314
315        let retryable = Error::CommandFailed {
316            command: "test".into(),
317            exit_code: 1,
318            stdout: String::new(),
319            stderr: String::new(),
320            working_dir: None,
321        };
322        assert!(policy.should_retry(&retryable));
323
324        let not_retryable = Error::CommandFailed {
325            command: "test".into(),
326            exit_code: 99,
327            stdout: String::new(),
328            stderr: String::new(),
329            working_dir: None,
330        };
331        assert!(!policy.should_retry(&not_retryable));
332    }
333
334    #[test]
335    fn test_should_not_retry_other_errors() {
336        let policy = RetryPolicy::new()
337            .retry_on_timeout(true)
338            .retry_on_exit_codes([1]);
339
340        let error = Error::NotFound;
341        assert!(!policy.should_retry(&error));
342    }
343
344    #[cfg(feature = "async")]
345    #[tokio::test]
346    async fn test_with_retry_succeeds_first_try() {
347        let policy = RetryPolicy::new().max_attempts(3);
348        let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
349        assert_eq!(result.unwrap(), 42);
350    }
351
352    #[cfg(feature = "async")]
353    #[tokio::test]
354    async fn test_with_retry_succeeds_after_failures() {
355        let policy = RetryPolicy::new()
356            .max_attempts(3)
357            .initial_backoff(Duration::from_millis(1))
358            .retry_on_timeout(true);
359
360        let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
361        let attempt_clone = attempt.clone();
362
363        let result = with_retry(&policy, || {
364            let attempt = attempt_clone.clone();
365            async move {
366                let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
367                if n < 2 {
368                    Err(Error::Timeout {
369                        timeout_seconds: 60,
370                    })
371                } else {
372                    Ok(42)
373                }
374            }
375        })
376        .await;
377
378        assert_eq!(result.unwrap(), 42);
379        assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
380    }
381
382    #[cfg(feature = "async")]
383    #[tokio::test]
384    async fn test_with_retry_exhausts_attempts() {
385        let policy = RetryPolicy::new()
386            .max_attempts(2)
387            .initial_backoff(Duration::from_millis(1))
388            .retry_on_timeout(true);
389
390        let result: crate::error::Result<()> = with_retry(&policy, || async {
391            Err(Error::Timeout {
392                timeout_seconds: 60,
393            })
394        })
395        .await;
396
397        assert!(matches!(result, Err(Error::Timeout { .. })));
398    }
399
400    #[cfg(feature = "async")]
401    #[tokio::test]
402    async fn test_with_retry_no_retry_on_non_retryable() {
403        let policy = RetryPolicy::new()
404            .max_attempts(3)
405            .initial_backoff(Duration::from_millis(1))
406            .retry_on_timeout(false);
407
408        let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
409        let attempt_clone = attempt.clone();
410
411        let result: crate::error::Result<()> = with_retry(&policy, || {
412            let attempt = attempt_clone.clone();
413            async move {
414                attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
415                Err(Error::Timeout {
416                    timeout_seconds: 60,
417                })
418            }
419        })
420        .await;
421
422        assert!(result.is_err());
423        // Should only attempt once since timeout is not retryable
424        assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
425    }
426
427    #[cfg(feature = "sync")]
428    #[test]
429    fn test_with_retry_sync_succeeds_first_try() {
430        let policy = RetryPolicy::new().max_attempts(3);
431        let result = with_retry_sync(&policy, || Ok::<_, Error>(42));
432        assert_eq!(result.unwrap(), 42);
433    }
434
435    #[cfg(feature = "sync")]
436    #[test]
437    fn test_with_retry_sync_succeeds_after_failures() {
438        use std::sync::atomic::{AtomicU32, Ordering};
439
440        let policy = RetryPolicy::new()
441            .max_attempts(3)
442            .initial_backoff(Duration::from_millis(1))
443            .retry_on_timeout(true);
444
445        let attempt = AtomicU32::new(0);
446        let result = with_retry_sync(&policy, || {
447            let n = attempt.fetch_add(1, Ordering::SeqCst);
448            if n < 2 {
449                Err(Error::Timeout {
450                    timeout_seconds: 60,
451                })
452            } else {
453                Ok(42)
454            }
455        });
456
457        assert_eq!(result.unwrap(), 42);
458        assert_eq!(attempt.load(Ordering::SeqCst), 3);
459    }
460
461    #[cfg(feature = "sync")]
462    #[test]
463    fn test_with_retry_sync_exhausts_attempts() {
464        let policy = RetryPolicy::new()
465            .max_attempts(2)
466            .initial_backoff(Duration::from_millis(1))
467            .retry_on_timeout(true);
468
469        let result: crate::error::Result<()> = with_retry_sync(&policy, || {
470            Err(Error::Timeout {
471                timeout_seconds: 60,
472            })
473        });
474
475        assert!(matches!(result, Err(Error::Timeout { .. })));
476    }
477
478    #[cfg(feature = "sync")]
479    #[test]
480    fn test_with_retry_sync_no_retry_on_non_retryable() {
481        use std::sync::atomic::{AtomicU32, Ordering};
482
483        let policy = RetryPolicy::new()
484            .max_attempts(3)
485            .initial_backoff(Duration::from_millis(1))
486            .retry_on_timeout(false);
487
488        let attempt = AtomicU32::new(0);
489        let result: crate::error::Result<()> = with_retry_sync(&policy, || {
490            attempt.fetch_add(1, Ordering::SeqCst);
491            Err(Error::Timeout {
492                timeout_seconds: 60,
493            })
494        });
495
496        assert!(result.is_err());
497        assert_eq!(attempt.load(Ordering::SeqCst), 1);
498    }
499}