pubmed_client/
retry.rs

1//! Retry logic with exponential backoff for handling transient network failures
2//!
3//! This module provides a configurable retry mechanism with exponential backoff
4//! and jitter to handle temporary network issues when communicating with NCBI APIs.
5
6use std::{fmt::Display, future::Future};
7
8use crate::time::{Duration, sleep};
9use rand::RngExt;
10use tracing::{debug, warn};
11
12/// Configuration for retry behavior
13#[derive(Debug, Clone)]
14pub struct RetryConfig {
15    /// Maximum number of retry attempts
16    pub max_retries: usize,
17    /// Initial delay before first retry
18    pub initial_delay: Duration,
19    /// Maximum delay between retries
20    pub max_delay: Duration,
21    /// Base for exponential backoff (typically 2.0)
22    pub backoff_base: f64,
23    /// Whether to add jitter to retry delays
24    pub use_jitter: bool,
25}
26
27impl Default for RetryConfig {
28    fn default() -> Self {
29        Self {
30            max_retries: 3,
31            initial_delay: Duration::from_secs(1),
32            max_delay: Duration::from_secs(60),
33            backoff_base: 2.0,
34            use_jitter: true,
35        }
36    }
37}
38
39impl RetryConfig {
40    /// Create a new retry configuration with custom settings
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Set the maximum number of retries
46    pub fn with_max_retries(mut self, max_retries: usize) -> Self {
47        self.max_retries = max_retries;
48        self
49    }
50
51    /// Set the initial delay
52    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
53        self.initial_delay = delay;
54        self
55    }
56
57    /// Set the maximum delay
58    pub fn with_max_delay(mut self, delay: Duration) -> Self {
59        self.max_delay = delay;
60        self
61    }
62
63    /// Disable jitter
64    pub fn without_jitter(mut self) -> Self {
65        self.use_jitter = false;
66        self
67    }
68
69    /// Calculate delay for a given retry attempt
70    fn calculate_delay(&self, attempt: usize) -> Duration {
71        let base_delay = self.initial_delay.as_millis() as f64;
72        let exponential_delay = base_delay * self.backoff_base.powi(attempt as i32);
73        let capped_delay = exponential_delay.min(self.max_delay.as_millis() as f64);
74
75        let final_delay = if self.use_jitter {
76            // Add jitter: random value between 0.5 and 1.5 of the calculated delay
77            let mut rng = rand::rng();
78            let jitter_factor = rng.random_range(0.5..1.5);
79            capped_delay * jitter_factor
80        } else {
81            capped_delay
82        };
83
84        Duration::from_millis(final_delay as u64)
85    }
86}
87
88/// Trait for errors that can be retried
89pub trait RetryableError {
90    /// Returns true if the error is transient and the operation should be retried
91    fn is_retryable(&self) -> bool;
92
93    /// Returns a human-readable description of why the error is/isn't retryable
94    fn retry_reason(&self) -> &str {
95        if self.is_retryable() {
96            "Transient error, will retry"
97        } else {
98            "Non-transient error, will not retry"
99        }
100    }
101}
102
103/// Execute an operation with retry logic
104///
105/// # Arguments
106///
107/// * `operation` - A closure that returns a future with the operation to retry
108/// * `config` - Retry configuration
109/// * `operation_name` - A descriptive name for logging
110///
111/// # Returns
112///
113/// Returns the result of the operation, or the last error if all retries failed
114pub async fn with_retry<F, Fut, T, E>(
115    mut operation: F,
116    config: &RetryConfig,
117    operation_name: &str,
118) -> Result<T, E>
119where
120    F: FnMut() -> Fut,
121    Fut: Future<Output = Result<T, E>>,
122    E: RetryableError + Display,
123{
124    let mut attempt = 0;
125
126    loop {
127        debug!(
128            operation = operation_name,
129            attempt = attempt,
130            max_retries = config.max_retries,
131            "Attempting operation"
132        );
133
134        match operation().await {
135            Ok(result) => {
136                if attempt > 0 {
137                    debug!(
138                        operation = operation_name,
139                        attempt = attempt,
140                        "Operation succeeded after retry"
141                    );
142                }
143                return Ok(result);
144            }
145            Err(error) => {
146                debug!(
147                    operation = operation_name,
148                    error = %error,
149                    is_retryable = error.is_retryable(),
150                    reason = error.retry_reason(),
151                    "Error encountered"
152                );
153
154                if !error.is_retryable() {
155                    debug!(
156                        operation = operation_name,
157                        error = %error,
158                        reason = error.retry_reason(),
159                        "Non-retryable error encountered"
160                    );
161                    return Err(error);
162                }
163
164                if attempt >= config.max_retries {
165                    warn!(
166                        operation = operation_name,
167                        attempts = attempt + 1,
168                        error = %error,
169                        "Max retries exceeded, operation failed"
170                    );
171                    return Err(error);
172                }
173
174                let delay = config.calculate_delay(attempt);
175                debug!(
176                    operation = operation_name,
177                    attempt = attempt + 1,
178                    max_retries = config.max_retries,
179                    delay_ms = delay.as_millis(),
180                    error = %error,
181                    "Retryable error encountered, will retry after delay"
182                );
183                sleep(delay).await;
184            }
185        }
186
187        attempt += 1;
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use std::sync::Arc;
195    use std::sync::atomic::{AtomicUsize, Ordering};
196
197    #[derive(Debug, thiserror::Error)]
198    enum TestError {
199        #[error("Retryable error")]
200        Retryable,
201        #[error("Non-retryable error")]
202        NonRetryable,
203    }
204
205    impl RetryableError for TestError {
206        fn is_retryable(&self) -> bool {
207            matches!(self, TestError::Retryable)
208        }
209    }
210
211    #[tokio::test]
212    async fn test_successful_operation() {
213        let config = RetryConfig::new().without_jitter();
214        let counter = Arc::new(AtomicUsize::new(0));
215        let counter_clone = counter.clone();
216
217        let result = with_retry(
218            || {
219                counter_clone.fetch_add(1, Ordering::SeqCst);
220                async { Ok::<_, TestError>(42) }
221            },
222            &config,
223            "test_operation",
224        )
225        .await;
226
227        assert_eq!(result.unwrap(), 42);
228        assert_eq!(counter.load(Ordering::SeqCst), 1); // Called only once
229    }
230
231    #[tokio::test]
232    async fn test_retry_then_success() {
233        let config = RetryConfig::new()
234            .with_max_retries(3)
235            .with_initial_delay(Duration::from_millis(10))
236            .without_jitter();
237
238        let counter = Arc::new(AtomicUsize::new(0));
239        let counter_clone = counter.clone();
240
241        let result = with_retry(
242            || {
243                let count = counter_clone.fetch_add(1, Ordering::SeqCst);
244                async move {
245                    if count < 2 {
246                        Err(TestError::Retryable)
247                    } else {
248                        Ok(42)
249                    }
250                }
251            },
252            &config,
253            "test_operation",
254        )
255        .await;
256
257        assert_eq!(result.unwrap(), 42);
258        assert_eq!(counter.load(Ordering::SeqCst), 3); // Failed twice, succeeded on third
259    }
260
261    #[tokio::test]
262    async fn test_non_retryable_error() {
263        let config = RetryConfig::new().with_max_retries(3);
264        let counter = Arc::new(AtomicUsize::new(0));
265        let counter_clone = counter.clone();
266
267        let result = with_retry(
268            || {
269                counter_clone.fetch_add(1, Ordering::SeqCst);
270                async { Err::<i32, _>(TestError::NonRetryable) }
271            },
272            &config,
273            "test_operation",
274        )
275        .await;
276
277        assert!(matches!(result, Err(TestError::NonRetryable)));
278        assert_eq!(counter.load(Ordering::SeqCst), 1); // Called only once
279    }
280
281    #[tokio::test]
282    async fn test_max_retries_exceeded() {
283        let config = RetryConfig::new()
284            .with_max_retries(2)
285            .with_initial_delay(Duration::from_millis(10))
286            .without_jitter();
287
288        let counter = Arc::new(AtomicUsize::new(0));
289        let counter_clone = counter.clone();
290
291        let result = with_retry(
292            || {
293                counter_clone.fetch_add(1, Ordering::SeqCst);
294                async { Err::<i32, _>(TestError::Retryable) }
295            },
296            &config,
297            "test_operation",
298        )
299        .await;
300
301        assert!(matches!(result, Err(TestError::Retryable)));
302        assert_eq!(counter.load(Ordering::SeqCst), 3); // Initial attempt + 2 retries
303    }
304
305    #[test]
306    fn test_exponential_backoff_calculation() {
307        let config = RetryConfig::new()
308            .with_initial_delay(Duration::from_secs(1))
309            .with_max_delay(Duration::from_secs(30))
310            .without_jitter();
311
312        // Test exponential growth
313        assert_eq!(config.calculate_delay(0), Duration::from_secs(1));
314        assert_eq!(config.calculate_delay(1), Duration::from_secs(2));
315        assert_eq!(config.calculate_delay(2), Duration::from_secs(4));
316        assert_eq!(config.calculate_delay(3), Duration::from_secs(8));
317        assert_eq!(config.calculate_delay(4), Duration::from_secs(16));
318
319        // Test max delay cap
320        assert_eq!(config.calculate_delay(5), Duration::from_secs(30)); // Would be 32, capped at 30
321        assert_eq!(config.calculate_delay(10), Duration::from_secs(30)); // Still capped
322    }
323
324    #[test]
325    fn test_jitter() {
326        let config = RetryConfig::new().with_initial_delay(Duration::from_secs(1));
327
328        // With jitter, delays should vary
329        let delay1 = config.calculate_delay(1);
330        let delay2 = config.calculate_delay(1);
331
332        // Both should be between 1-3 seconds (2 seconds * 0.5-1.5 jitter)
333        assert!(delay1.as_millis() >= 1000);
334        assert!(delay1.as_millis() <= 3000);
335        assert!(delay2.as_millis() >= 1000);
336        assert!(delay2.as_millis() <= 3000);
337
338        // They're unlikely to be exactly the same with jitter
339        // (though theoretically possible, so we don't assert inequality)
340    }
341}