subx_cli/services/ai/
retry.rs

1use crate::Result;
2use tokio::time::{Duration, sleep};
3
4/// Retry configuration for AI service operations.
5///
6/// Configures the retry behavior for AI API calls, including
7/// backoff strategies and maximum attempt limits.
8pub struct RetryConfig {
9    /// Maximum number of retry attempts
10    pub max_attempts: usize,
11    /// Initial delay between retries
12    pub base_delay: Duration,
13    /// Maximum delay between retries
14    pub max_delay: Duration,
15    /// Multiplier for exponential backoff
16    pub backoff_multiplier: f64,
17}
18
19impl Default for RetryConfig {
20    fn default() -> Self {
21        Self {
22            max_attempts: 3,
23            base_delay: Duration::from_millis(1000),
24            max_delay: Duration::from_secs(30),
25            backoff_multiplier: 2.0,
26        }
27    }
28}
29
30/// Retries an operation with an exponential backoff mechanism.
31pub async fn retry_with_backoff<F, Fut, T>(operation: F, config: &RetryConfig) -> Result<T>
32where
33    F: Fn() -> Fut,
34    Fut: std::future::Future<Output = Result<T>>,
35{
36    let mut last_error = None;
37
38    for attempt in 0..config.max_attempts {
39        match operation().await {
40            Ok(result) => return Ok(result),
41            Err(e) => {
42                last_error = Some(e);
43
44                if attempt < config.max_attempts - 1 {
45                    let delay = std::cmp::min(
46                        Duration::from_millis(
47                            (config.base_delay.as_millis() as f64
48                                * config.backoff_multiplier.powi(attempt as i32))
49                                as u64,
50                        ),
51                        config.max_delay,
52                    );
53                    sleep(delay).await;
54                }
55            }
56        }
57    }
58
59    Err(last_error.unwrap())
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::error::SubXError;
66    use std::sync::{Arc, Mutex};
67    use std::time::Instant;
68
69    /// Test basic retry mechanism
70    #[tokio::test]
71    async fn test_retry_success_on_second_attempt() {
72        let config = RetryConfig {
73            max_attempts: 3,
74            base_delay: Duration::from_millis(10),
75            max_delay: Duration::from_secs(1),
76            backoff_multiplier: 2.0,
77        };
78
79        let attempt_count = Arc::new(Mutex::new(0));
80        let attempt_count_clone = attempt_count.clone();
81
82        let operation = || async {
83            let mut count = attempt_count_clone.lock().unwrap();
84            *count += 1;
85            if *count == 1 {
86                Err(SubXError::AiService("First attempt fails".to_string()))
87            } else {
88                Ok("Success on second attempt".to_string())
89            }
90        };
91
92        let result = retry_with_backoff(operation, &config).await;
93        assert!(result.is_ok());
94        assert_eq!(result.unwrap(), "Success on second attempt");
95        assert_eq!(*attempt_count.lock().unwrap(), 2);
96    }
97
98    /// Test maximum retry attempts limit
99    #[tokio::test]
100    async fn test_retry_exhaust_max_attempts() {
101        let config = RetryConfig {
102            max_attempts: 2,
103            base_delay: Duration::from_millis(10),
104            max_delay: Duration::from_secs(1),
105            backoff_multiplier: 2.0,
106        };
107
108        let attempt_count = Arc::new(Mutex::new(0));
109        let attempt_count_clone = attempt_count.clone();
110
111        let operation = || async {
112            let mut count = attempt_count_clone.lock().unwrap();
113            *count += 1;
114            Err(SubXError::AiService("Always fails".to_string()))
115        };
116
117        let result: Result<String> = retry_with_backoff(operation, &config).await;
118        assert!(result.is_err());
119        assert_eq!(*attempt_count.lock().unwrap(), 2);
120    }
121
122    /// Test exponential backoff delay
123    #[tokio::test]
124    async fn test_exponential_backoff_timing() {
125        let config = RetryConfig {
126            max_attempts: 3,
127            base_delay: Duration::from_millis(50),
128            max_delay: Duration::from_millis(200),
129            backoff_multiplier: 2.0,
130        };
131
132        let attempt_times = Arc::new(Mutex::new(Vec::new()));
133        let attempt_times_clone = attempt_times.clone();
134
135        let operation = || async {
136            let start_time = Instant::now();
137            attempt_times_clone.lock().unwrap().push(start_time);
138            Err(SubXError::AiService(
139                "Always fails for timing test".to_string(),
140            ))
141        };
142
143        let _overall_start = Instant::now();
144        let _result: Result<String> = retry_with_backoff(operation, &config).await;
145
146        let times = attempt_times.lock().unwrap();
147        assert_eq!(times.len(), 3);
148
149        // Verify delay times increase (considering execution time tolerance)
150        if times.len() >= 2 {
151            let delay1 = times[1].duration_since(times[0]);
152            // First delay should be approximately 50ms (±20ms tolerance)
153            assert!(delay1 >= Duration::from_millis(30));
154            assert!(delay1 <= Duration::from_millis(100));
155        }
156    }
157
158    /// Test maximum delay cap limit
159    #[tokio::test]
160    async fn test_max_delay_cap() {
161        let config = RetryConfig {
162            max_attempts: 5,
163            base_delay: Duration::from_millis(100),
164            max_delay: Duration::from_millis(200), // Low cap
165            backoff_multiplier: 3.0,               // High multiplier
166        };
167
168        let attempt_times = Arc::new(Mutex::new(Vec::new()));
169        let attempt_times_clone = attempt_times.clone();
170
171        let operation = || async {
172            attempt_times_clone.lock().unwrap().push(Instant::now());
173            Err(SubXError::AiService("Always fails".to_string()))
174        };
175
176        let _result: Result<String> = retry_with_backoff(operation, &config).await;
177
178        let times = attempt_times.lock().unwrap();
179
180        // Verify subsequent delays don't exceed max_delay
181        if times.len() >= 3 {
182            let delay2 = times[2].duration_since(times[1]);
183            // Second delay should be capped at max_delay (±50ms tolerance)
184            assert!(delay2 <= Duration::from_millis(250));
185        }
186    }
187
188    /// Test configuration validity validation
189    #[test]
190    fn test_retry_config_validation() {
191        // Valid configuration
192        let valid_config = RetryConfig {
193            max_attempts: 3,
194            base_delay: Duration::from_millis(100),
195            max_delay: Duration::from_secs(1),
196            backoff_multiplier: 2.0,
197        };
198        assert!(valid_config.base_delay <= valid_config.max_delay);
199        assert!(valid_config.max_attempts > 0);
200        assert!(valid_config.backoff_multiplier > 1.0);
201    }
202
203    /// Test AI service integration simulation scenario
204    #[tokio::test]
205    async fn test_ai_service_integration_simulation() {
206        let config = RetryConfig {
207            max_attempts: 3,
208            base_delay: Duration::from_millis(10),
209            max_delay: Duration::from_secs(1),
210            backoff_multiplier: 2.0,
211        };
212
213        // Simulate AI service calls
214        let request_count = Arc::new(Mutex::new(0));
215        let request_count_clone = request_count.clone();
216
217        let mock_ai_request = || async {
218            let mut count = request_count_clone.lock().unwrap();
219            *count += 1;
220
221            match *count {
222                1 => Err(SubXError::AiService("Network timeout".to_string())),
223                2 => Err(SubXError::AiService("Rate limit exceeded".to_string())),
224                3 => Ok("AI analysis complete".to_string()),
225                _ => unreachable!(),
226            }
227        };
228
229        let result = retry_with_backoff(mock_ai_request, &config).await;
230        assert!(result.is_ok());
231        assert_eq!(result.unwrap(), "AI analysis complete");
232        assert_eq!(*request_count.lock().unwrap(), 3);
233    }
234}