salesforce_client/
retry.rs

1//! Retry logic with exponential backoff
2//!
3//! Automatically retries failed requests with intelligent backoff strategies.
4
5use crate::error::{SfError, SfResult};
6// Retry logic implementation without backoff crate due to lifetime issues
7use std::time::Duration;
8use tracing::{debug, warn};
9
10/// Configuration for retry behavior
11#[derive(Debug, Clone)]
12pub struct RetryConfig {
13    /// Maximum number of retry attempts
14    pub max_retries: u32,
15
16    /// Initial backoff duration
17    pub initial_interval: Duration,
18
19    /// Maximum backoff duration
20    pub max_interval: Duration,
21
22    /// Multiplier for exponential backoff
23    pub multiplier: f64,
24
25    /// Maximum elapsed time before giving up
26    pub max_elapsed_time: Option<Duration>,
27}
28
29impl Default for RetryConfig {
30    fn default() -> Self {
31        Self {
32            max_retries: 3,
33            initial_interval: Duration::from_millis(500),
34            max_interval: Duration::from_secs(30),
35            multiplier: 2.0,
36            max_elapsed_time: Some(Duration::from_secs(300)), // 5 minutes
37        }
38    }
39}
40
41impl RetryConfig {
42    /// Create a new retry config with defaults
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Set maximum number of retries
48    pub fn max_retries(mut self, max: u32) -> Self {
49        self.max_retries = max;
50        self
51    }
52
53    /// Set initial backoff interval
54    pub fn initial_interval(mut self, duration: Duration) -> Self {
55        self.initial_interval = duration;
56        self
57    }
58
59    /// Set maximum backoff interval
60    pub fn max_interval(mut self, duration: Duration) -> Self {
61        self.max_interval = duration;
62        self
63    }
64
65    /// Disable retry (for testing)
66    pub fn no_retry() -> Self {
67        Self {
68            max_retries: 0,
69            ..Default::default()
70        }
71    }
72}
73
74/// Determines if an error is retryable
75pub(crate) fn is_retryable(error: &SfError) -> bool {
76    match error {
77        // Network errors are retryable
78        SfError::Network(_) => true,
79
80        // Rate limits are retryable (with backoff)
81        SfError::RateLimit { .. } => true,
82
83        // Timeout is retryable
84        SfError::Timeout { .. } => true,
85
86        // API errors: only retry on specific status codes
87        SfError::Api { status, .. } => {
88            matches!(
89                *status,
90                // 408 Request Timeout
91                408 |
92                // 429 Too Many Requests
93                429 |
94                // 500 Internal Server Error
95                500 |
96                // 502 Bad Gateway
97                502 |
98                // 503 Service Unavailable
99                503 |
100                // 504 Gateway Timeout
101                504
102            )
103        }
104
105        // Other errors are not retryable
106        _ => false,
107    }
108}
109
110/// Execute an async operation with retry logic
111///
112/// # Example
113/// ```ignore
114/// let result = with_retry(config, || async {
115///     client.query::<Account>("SELECT Id FROM Account").await
116/// }).await?;
117/// ```
118pub async fn with_retry<F, Fut, T>(config: &RetryConfig, operation: F) -> SfResult<T>
119where
120    F: Fn() -> Fut,
121    Fut: std::future::Future<Output = SfResult<T>>,
122{
123    if config.max_retries == 0 {
124        // No retry, execute once
125        return operation().await;
126    }
127
128    let mut attempt = 0;
129    let mut delay = config.initial_interval;
130
131    loop {
132        attempt += 1;
133
134        match operation().await {
135            Ok(result) => {
136                if attempt > 1 {
137                    debug!("Operation succeeded after {} attempts", attempt);
138                }
139                return Ok(result);
140            }
141            Err(e) => {
142                if is_retryable(&e) && attempt <= config.max_retries {
143                    warn!(
144                        "Attempt {} failed: {}. Retrying in {:?}...",
145                        attempt, e, delay
146                    );
147                    tokio::time::sleep(delay).await;
148
149                    // Exponential backoff
150                    delay = Duration::min(
151                        Duration::from_secs_f64(delay.as_secs_f64() * config.multiplier),
152                        config.max_interval,
153                    );
154                } else {
155                    if attempt > config.max_retries {
156                        warn!("Max retries ({}) exceeded. Giving up.", config.max_retries);
157                    } else {
158                        debug!("Error is not retryable: {}", e);
159                    }
160                    return Err(e);
161                }
162            }
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_retry_config_builder() {
173        let config = RetryConfig::new()
174            .max_retries(5)
175            .initial_interval(Duration::from_millis(100));
176
177        assert_eq!(config.max_retries, 5);
178        assert_eq!(config.initial_interval, Duration::from_millis(100));
179    }
180
181    #[test]
182    fn test_is_retryable() {
183        // Retryable errors
184        assert!(is_retryable(&SfError::RateLimit { retry_after: None }));
185        assert!(is_retryable(&SfError::Timeout { seconds: 30 }));
186        assert!(is_retryable(&SfError::Api {
187            status: 503,
188            body: "Service Unavailable".to_string()
189        }));
190
191        // Non-retryable errors
192        assert!(!is_retryable(&SfError::Auth("Invalid token".to_string())));
193        assert!(!is_retryable(&SfError::NotFound {
194            sobject: "Account".to_string(),
195            id: "123".to_string()
196        }));
197        assert!(!is_retryable(&SfError::Api {
198            status: 400,
199            body: "Bad Request".to_string()
200        }));
201    }
202
203    #[tokio::test]
204    async fn test_with_retry_success() {
205        let config = RetryConfig::no_retry();
206
207        let result = with_retry(&config, || async { Ok::<i32, SfError>(42) }).await;
208
209        assert!(result.is_ok());
210        assert_eq!(result.unwrap(), 42);
211    }
212
213    #[tokio::test]
214    async fn test_with_retry_non_retryable_error() {
215        let config = RetryConfig::new().max_retries(3);
216
217        let result = with_retry(&config, || async {
218            Err::<i32, SfError>(SfError::Auth("Invalid token".to_string()))
219        })
220        .await;
221
222        assert!(result.is_err());
223        // Should not retry non-retryable errors
224    }
225}