Skip to main content

codex_wrapper/
retry.rs

1use std::time::Duration;
2
3use tracing::warn;
4
5use crate::error::Error;
6
7/// Retry policy for transient CLI failures.
8///
9/// Configure max attempts, backoff strategy, and which errors to retry.
10///
11/// # Example
12///
13/// ```
14/// use codex_wrapper::RetryPolicy;
15/// use std::time::Duration;
16///
17/// let policy = RetryPolicy::new()
18///     .max_attempts(3)
19///     .initial_backoff(Duration::from_secs(1))
20///     .exponential()
21///     .retry_on_timeout(true)
22///     .retry_on_exit_codes([1, 2]);
23/// ```
24#[derive(Debug, Clone)]
25pub struct RetryPolicy {
26    pub(crate) max_attempts: u32,
27    pub(crate) initial_backoff: Duration,
28    pub(crate) max_backoff: Duration,
29    pub(crate) backoff_strategy: BackoffStrategy,
30    pub(crate) retry_on_timeout: bool,
31    pub(crate) retry_exit_codes: Vec<i32>,
32}
33
34/// Backoff strategy between retry attempts.
35#[derive(Debug, Clone, Copy)]
36pub enum BackoffStrategy {
37    /// Fixed delay between attempts.
38    Fixed,
39    /// Exponential backoff (delay doubles each attempt).
40    Exponential,
41}
42
43impl Default for RetryPolicy {
44    fn default() -> Self {
45        Self {
46            max_attempts: 3,
47            initial_backoff: Duration::from_secs(1),
48            max_backoff: Duration::from_secs(30),
49            backoff_strategy: BackoffStrategy::Fixed,
50            retry_on_timeout: true,
51            retry_exit_codes: Vec::new(),
52        }
53    }
54}
55
56impl RetryPolicy {
57    /// Create a new retry policy with default settings (3 attempts, 1s fixed backoff).
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Set the maximum number of attempts (including the initial attempt).
64    ///
65    /// A value of 1 means no retries.
66    #[must_use]
67    pub fn max_attempts(mut self, n: u32) -> Self {
68        self.max_attempts = n;
69        self
70    }
71
72    /// Set the initial delay before the first retry.
73    #[must_use]
74    pub fn initial_backoff(mut self, duration: Duration) -> Self {
75        self.initial_backoff = duration;
76        self
77    }
78
79    /// Set the maximum delay between retries (caps exponential growth).
80    #[must_use]
81    pub fn max_backoff(mut self, duration: Duration) -> Self {
82        self.max_backoff = duration;
83        self
84    }
85
86    /// Use fixed backoff (same delay between each attempt).
87    #[must_use]
88    pub fn fixed(mut self) -> Self {
89        self.backoff_strategy = BackoffStrategy::Fixed;
90        self
91    }
92
93    /// Use exponential backoff (delay doubles each attempt, capped by max_backoff).
94    #[must_use]
95    pub fn exponential(mut self) -> Self {
96        self.backoff_strategy = BackoffStrategy::Exponential;
97        self
98    }
99
100    /// Retry on timeout errors.
101    #[must_use]
102    pub fn retry_on_timeout(mut self, retry: bool) -> Self {
103        self.retry_on_timeout = retry;
104        self
105    }
106
107    /// Retry on specific non-zero exit codes.
108    #[must_use]
109    pub fn retry_on_exit_codes(mut self, codes: impl IntoIterator<Item = i32>) -> Self {
110        self.retry_exit_codes = codes.into_iter().collect();
111        self
112    }
113
114    /// Calculate the delay for a given attempt (0-indexed).
115    pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
116        let delay = match self.backoff_strategy {
117            BackoffStrategy::Fixed => self.initial_backoff,
118            BackoffStrategy::Exponential => self
119                .initial_backoff
120                .saturating_mul(2u32.saturating_pow(attempt)),
121        };
122        delay.min(self.max_backoff)
123    }
124
125    /// Check if the given error should be retried.
126    pub(crate) fn should_retry(&self, error: &Error) -> bool {
127        match error {
128            Error::Timeout { .. } => self.retry_on_timeout,
129            // A classified failure is a deterministic rejection: bad
130            // credentials, a rejected config, an untrusted directory, a
131            // session that does not exist. Re-running gets the same answer,
132            // so these are never retried even when the exit code is listed.
133            _ if error.is_deterministic_failure() => false,
134            Error::CommandFailed { exit_code, .. } => self.retry_exit_codes.contains(exit_code),
135            _ => false,
136        }
137    }
138}
139
140/// Execute a fallible async operation with retry.
141pub(crate) async fn with_retry<F, Fut, T>(
142    policy: &RetryPolicy,
143    mut operation: F,
144) -> crate::error::Result<T>
145where
146    F: FnMut() -> Fut,
147    Fut: std::future::Future<Output = crate::error::Result<T>>,
148{
149    let mut last_error = None;
150
151    for attempt in 0..policy.max_attempts {
152        match operation().await {
153            Ok(result) => return Ok(result),
154            Err(e) => {
155                if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
156                    let delay = policy.delay_for_attempt(attempt);
157                    warn!(
158                        attempt = attempt + 1,
159                        max_attempts = policy.max_attempts,
160                        delay_ms = delay.as_millis() as u64,
161                        error = %e,
162                        "retrying after transient error"
163                    );
164                    tokio::time::sleep(delay).await;
165                    last_error = Some(e);
166                } else {
167                    return Err(e);
168                }
169            }
170        }
171    }
172
173    Err(last_error.expect("at least one attempt was made"))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_default_policy() {
182        let policy = RetryPolicy::new();
183        assert_eq!(policy.max_attempts, 3);
184        assert_eq!(policy.initial_backoff, Duration::from_secs(1));
185        assert!(policy.retry_on_timeout);
186        assert!(policy.retry_exit_codes.is_empty());
187    }
188
189    #[test]
190    fn test_builder() {
191        let policy = RetryPolicy::new()
192            .max_attempts(5)
193            .initial_backoff(Duration::from_millis(500))
194            .exponential()
195            .retry_on_timeout(false)
196            .retry_on_exit_codes([1, 2, 3]);
197
198        assert_eq!(policy.max_attempts, 5);
199        assert_eq!(policy.initial_backoff, Duration::from_millis(500));
200        assert!(!policy.retry_on_timeout);
201        assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
202    }
203
204    #[test]
205    fn test_fixed_delay() {
206        let policy = RetryPolicy::new()
207            .initial_backoff(Duration::from_secs(2))
208            .fixed();
209
210        assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
211        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
212        assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
213    }
214
215    #[test]
216    fn test_exponential_delay() {
217        let policy = RetryPolicy::new()
218            .initial_backoff(Duration::from_secs(1))
219            .max_backoff(Duration::from_secs(30))
220            .exponential();
221
222        assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
223        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
224        assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
225        assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
226        // Capped at max_backoff
227        assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
228    }
229
230    #[test]
231    fn test_should_retry_timeout() {
232        let policy = RetryPolicy::new().retry_on_timeout(true);
233        let error = Error::Timeout {
234            timeout_seconds: 60,
235        };
236        assert!(policy.should_retry(&error));
237
238        let policy = RetryPolicy::new().retry_on_timeout(false);
239        assert!(!policy.should_retry(&error));
240    }
241
242    #[test]
243    fn test_should_retry_exit_code() {
244        let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
245
246        let retryable = Error::CommandFailed {
247            command: "test".into(),
248            exit_code: 1,
249            stdout: String::new(),
250            stderr: String::new(),
251            working_dir: None,
252        };
253        assert!(policy.should_retry(&retryable));
254
255        let not_retryable = Error::CommandFailed {
256            command: "test".into(),
257            exit_code: 99,
258            stdout: String::new(),
259            stderr: String::new(),
260            working_dir: None,
261        };
262        assert!(!policy.should_retry(&not_retryable));
263    }
264
265    #[test]
266    fn test_should_not_retry_other_errors() {
267        let policy = RetryPolicy::new()
268            .retry_on_timeout(true)
269            .retry_on_exit_codes([1]);
270
271        let error = Error::NotFound;
272        assert!(!policy.should_retry(&error));
273    }
274
275    #[tokio::test]
276    async fn test_with_retry_succeeds_first_try() {
277        let policy = RetryPolicy::new().max_attempts(3);
278        let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
279        assert_eq!(result.unwrap(), 42);
280    }
281
282    #[tokio::test]
283    async fn test_with_retry_succeeds_after_failures() {
284        let policy = RetryPolicy::new()
285            .max_attempts(3)
286            .initial_backoff(Duration::from_millis(1))
287            .retry_on_timeout(true);
288
289        let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
290        let attempt_clone = attempt.clone();
291
292        let result = with_retry(&policy, || {
293            let attempt = attempt_clone.clone();
294            async move {
295                let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
296                if n < 2 {
297                    Err(Error::Timeout {
298                        timeout_seconds: 60,
299                    })
300                } else {
301                    Ok(42)
302                }
303            }
304        })
305        .await;
306
307        assert_eq!(result.unwrap(), 42);
308        assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
309    }
310
311    #[tokio::test]
312    async fn test_with_retry_exhausts_attempts() {
313        let policy = RetryPolicy::new()
314            .max_attempts(2)
315            .initial_backoff(Duration::from_millis(1))
316            .retry_on_timeout(true);
317
318        let result: crate::error::Result<()> = with_retry(&policy, || async {
319            Err(Error::Timeout {
320                timeout_seconds: 60,
321            })
322        })
323        .await;
324
325        assert!(matches!(result, Err(Error::Timeout { .. })));
326    }
327
328    #[tokio::test]
329    async fn test_with_retry_no_retry_on_non_retryable() {
330        let policy = RetryPolicy::new()
331            .max_attempts(3)
332            .initial_backoff(Duration::from_millis(1))
333            .retry_on_timeout(false);
334
335        let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
336        let attempt_clone = attempt.clone();
337
338        let result: crate::error::Result<()> = with_retry(&policy, || {
339            let attempt = attempt_clone.clone();
340            async move {
341                attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
342                Err(Error::Timeout {
343                    timeout_seconds: 60,
344                })
345            }
346        })
347        .await;
348
349        assert!(result.is_err());
350        // Should only attempt once since timeout is not retryable
351        assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
352    }
353}