Skip to main content

claude_sdk/
retry.rs

1//! Retry logic with exponential backoff
2//!
3//! This module provides retry strategies for handling transient failures
4//! like rate limits and server errors.
5
6use crate::error::Result;
7use std::time::Duration;
8use tokio::time::sleep;
9use tracing::{debug, warn};
10
11/// Retry configuration for API requests
12#[derive(Debug, Clone)]
13pub struct RetryConfig {
14    /// Maximum number of retry attempts
15    pub max_attempts: u32,
16
17    /// Initial backoff duration
18    pub initial_backoff: Duration,
19
20    /// Maximum backoff duration
21    pub max_backoff: Duration,
22
23    /// Backoff multiplier (exponential growth)
24    pub backoff_multiplier: f64,
25
26    /// Whether to respect retry-after headers
27    pub respect_retry_after: bool,
28}
29
30impl Default for RetryConfig {
31    fn default() -> Self {
32        Self {
33            max_attempts: 3,
34            initial_backoff: Duration::from_millis(500),
35            max_backoff: Duration::from_secs(60),
36            backoff_multiplier: 2.0,
37            respect_retry_after: true,
38        }
39    }
40}
41
42impl RetryConfig {
43    /// Create a new retry configuration
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Set maximum number of attempts
49    pub fn with_max_attempts(mut self, attempts: u32) -> Self {
50        self.max_attempts = attempts;
51        self
52    }
53
54    /// Set initial backoff duration
55    pub fn with_initial_backoff(mut self, duration: Duration) -> Self {
56        self.initial_backoff = duration;
57        self
58    }
59
60    /// Set maximum backoff duration
61    pub fn with_max_backoff(mut self, duration: Duration) -> Self {
62        self.max_backoff = duration;
63        self
64    }
65
66    /// Set backoff multiplier for exponential growth
67    pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
68        self.backoff_multiplier = multiplier;
69        self
70    }
71
72    /// Calculate backoff duration for a given attempt number
73    fn calculate_backoff(&self, attempt: u32, retry_after: Option<u64>) -> Duration {
74        // Respect retry-after header if present
75        if self.respect_retry_after {
76            if let Some(seconds) = retry_after {
77                return Duration::from_secs(seconds).min(self.max_backoff);
78            }
79        }
80
81        // Exponential backoff: initial * multiplier^attempt
82        let backoff_secs =
83            self.initial_backoff.as_secs_f64() * self.backoff_multiplier.powi(attempt as i32);
84
85        Duration::from_secs_f64(backoff_secs.min(self.max_backoff.as_secs_f64()))
86    }
87}
88
89/// Execute an async operation with retry logic
90///
91/// # Example
92///
93/// ```rust,no_run
94/// use claude_sdk::retry::{retry_with_backoff, RetryConfig};
95///
96/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
97/// let config = RetryConfig::new().with_max_attempts(5);
98///
99/// let result = retry_with_backoff(config, || async {
100///     // Your API call here
101///     Ok::<_, claude_sdk::Error>("success")
102/// }).await?;
103/// # Ok(())
104/// # }
105/// ```
106pub async fn retry_with_backoff<F, Fut, T>(config: RetryConfig, mut operation: F) -> Result<T>
107where
108    F: FnMut() -> Fut,
109    Fut: std::future::Future<Output = Result<T>>,
110{
111    let mut attempt = 0;
112
113    loop {
114        attempt += 1;
115
116        debug!("Attempt {}/{}", attempt, config.max_attempts);
117
118        match operation().await {
119            Ok(result) => {
120                if attempt > 1 {
121                    debug!("Request succeeded after {} attempts", attempt);
122                }
123                return Ok(result);
124            }
125            Err(error) => {
126                // Check if we should retry
127                if !error.is_retryable() {
128                    debug!("Error is not retryable: {:?}", error);
129                    return Err(error);
130                }
131
132                // Check if we've exhausted retries
133                if attempt >= config.max_attempts {
134                    warn!(
135                        "Max retry attempts ({}) reached, failing",
136                        config.max_attempts
137                    );
138                    return Err(error);
139                }
140
141                // Calculate backoff
142                let retry_after = error.retry_after();
143                let backoff = config.calculate_backoff(attempt - 1, retry_after);
144
145                warn!(
146                    "Request failed (attempt {}/{}): {:?}. Retrying in {:?}",
147                    attempt, config.max_attempts, error, backoff
148                );
149
150                // Sleep before retry
151                sleep(backoff).await;
152            }
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::error::Error;
161    use std::sync::atomic::{AtomicU32, Ordering};
162    use std::sync::Arc;
163
164    #[test]
165    fn test_default_config() {
166        let config = RetryConfig::default();
167        assert_eq!(config.max_attempts, 3);
168        assert_eq!(config.initial_backoff, Duration::from_millis(500));
169        assert_eq!(config.max_backoff, Duration::from_secs(60));
170    }
171
172    #[test]
173    fn test_config_builder() {
174        let config = RetryConfig::new()
175            .with_max_attempts(5)
176            .with_initial_backoff(Duration::from_secs(1))
177            .with_max_backoff(Duration::from_secs(30));
178
179        assert_eq!(config.max_attempts, 5);
180        assert_eq!(config.initial_backoff, Duration::from_secs(1));
181        assert_eq!(config.max_backoff, Duration::from_secs(30));
182    }
183
184    #[test]
185    fn test_calculate_backoff() {
186        let config = RetryConfig::new()
187            .with_initial_backoff(Duration::from_secs(1))
188            .with_backoff_multiplier(2.0);
189
190        // First retry: 1s
191        assert_eq!(config.calculate_backoff(0, None), Duration::from_secs(1));
192
193        // Second retry: 2s
194        assert_eq!(config.calculate_backoff(1, None), Duration::from_secs(2));
195
196        // Third retry: 4s
197        assert_eq!(config.calculate_backoff(2, None), Duration::from_secs(4));
198    }
199
200    #[test]
201    fn test_respect_retry_after() {
202        let config = RetryConfig::new().with_initial_backoff(Duration::from_secs(1));
203
204        // When retry_after is provided, use it instead of exponential backoff
205        let backoff = config.calculate_backoff(0, Some(10));
206        assert_eq!(backoff, Duration::from_secs(10));
207    }
208
209    #[test]
210    fn test_max_backoff_cap() {
211        let config = RetryConfig::new()
212            .with_initial_backoff(Duration::from_secs(1))
213            .with_max_backoff(Duration::from_secs(5))
214            .with_backoff_multiplier(10.0);
215
216        // Even with large multiplier, should cap at max_backoff
217        let backoff = config.calculate_backoff(10, None);
218        assert!(backoff <= Duration::from_secs(5));
219    }
220
221    #[tokio::test]
222    async fn test_retry_succeeds_first_attempt() {
223        let config = RetryConfig::new();
224        let call_count = Arc::new(AtomicU32::new(0));
225        let count = call_count.clone();
226
227        let result = retry_with_backoff(config, || {
228            let count = count.clone();
229            async move {
230                count.fetch_add(1, Ordering::SeqCst);
231                Ok::<_, Error>("success")
232            }
233        })
234        .await;
235
236        assert!(result.is_ok());
237        assert_eq!(call_count.load(Ordering::SeqCst), 1);
238    }
239
240    #[tokio::test]
241    async fn test_retry_succeeds_after_failures() {
242        let config = RetryConfig::new().with_initial_backoff(Duration::from_millis(10));
243        let call_count = Arc::new(AtomicU32::new(0));
244        let count = call_count.clone();
245
246        let result = retry_with_backoff(config, || {
247            let count = count.clone();
248            async move {
249                let current = count.fetch_add(1, Ordering::SeqCst) + 1;
250                if current < 3 {
251                    Err(Error::Server {
252                        status: 503,
253                        message: "Service unavailable".into(),
254                    })
255                } else {
256                    Ok::<_, Error>("success")
257                }
258            }
259        })
260        .await;
261
262        assert!(result.is_ok());
263        assert_eq!(call_count.load(Ordering::SeqCst), 3);
264    }
265
266    #[tokio::test]
267    async fn test_retry_fails_non_retryable() {
268        let config = RetryConfig::new();
269        let call_count = Arc::new(AtomicU32::new(0));
270        let count = call_count.clone();
271
272        let result = retry_with_backoff(config, || {
273            let count = count.clone();
274            async move {
275                count.fetch_add(1, Ordering::SeqCst);
276                Err::<String, _>(Error::Authentication("Bad key".into()))
277            }
278        })
279        .await;
280
281        assert!(result.is_err());
282        assert_eq!(call_count.load(Ordering::SeqCst), 1); // Should not retry auth errors
283    }
284
285    #[tokio::test]
286    async fn test_retry_exhausts_attempts() {
287        let config = RetryConfig::new()
288            .with_max_attempts(2)
289            .with_initial_backoff(Duration::from_millis(10));
290        let call_count = Arc::new(AtomicU32::new(0));
291        let count = call_count.clone();
292
293        let result = retry_with_backoff(config, || {
294            let count = count.clone();
295            async move {
296                count.fetch_add(1, Ordering::SeqCst);
297                Err::<String, _>(Error::Server {
298                    status: 500,
299                    message: "Error".into(),
300                })
301            }
302        })
303        .await;
304
305        assert!(result.is_err());
306        assert_eq!(call_count.load(Ordering::SeqCst), 2);
307    }
308}