Skip to main content

tuitbot_core/x_api/
retry.rs

1//! Retry helper for transient X API / scraper errors.
2//!
3//! Provides `retry_with_backoff` — an async wrapper that retries a fallible
4//! async operation using exponential backoff with full jitter.
5//!
6//! Use for scraper mutations and queries where transient network or 5xx
7//! errors are expected.  Never retry non-retryable errors (401, 403, etc.).
8
9use std::time::Duration;
10
11use rand::Rng;
12
13use crate::error::XApiError;
14
15/// Configuration for the retry policy.
16#[derive(Debug, Clone, Copy)]
17pub struct RetryConfig {
18    /// Maximum number of attempts (including the first).  Default: 3.
19    pub max_attempts: u32,
20    /// Base delay before the first retry.  Default: 500 ms.
21    pub base_delay: Duration,
22    /// Maximum delay cap (jitter stays within `[0, capped_delay]`).  Default: 8 s.
23    pub max_delay: Duration,
24}
25
26impl Default for RetryConfig {
27    fn default() -> Self {
28        Self {
29            max_attempts: 3,
30            base_delay: Duration::from_millis(500),
31            max_delay: Duration::from_secs(8),
32        }
33    }
34}
35
36/// Retry `op` up to `cfg.max_attempts` times on retryable errors.
37///
38/// Delay between attempts uses exponential backoff with full jitter:
39/// `sleep(rand(0, min(base * 2^attempt, max_delay)))`.
40///
41/// Returns the last error unchanged if all attempts are exhausted or
42/// the error is non-retryable.
43pub async fn retry_with_backoff<F, Fut, T>(cfg: RetryConfig, mut op: F) -> Result<T, XApiError>
44where
45    F: FnMut() -> Fut,
46    Fut: std::future::Future<Output = Result<T, XApiError>>,
47{
48    let mut attempt = 0u32;
49    loop {
50        match op().await {
51            Ok(v) => return Ok(v),
52            Err(e) if !e.is_retryable() => return Err(e),
53            Err(e) => {
54                attempt += 1;
55                if attempt >= cfg.max_attempts {
56                    return Err(e);
57                }
58
59                // Exponential backoff with full jitter.
60                let cap_ms = cfg
61                    .max_delay
62                    .min(cfg.base_delay * 2u32.saturating_pow(attempt))
63                    .as_millis() as u64;
64                let jitter_ms = rand::rng().random_range(0..=cap_ms);
65                let delay = Duration::from_millis(jitter_ms);
66
67                tracing::debug!(
68                    attempt,
69                    delay_ms = jitter_ms,
70                    error = %e,
71                    "Retryable scraper error — backing off before retry"
72                );
73
74                tokio::time::sleep(delay).await;
75            }
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn default_config_values() {
86        let cfg = RetryConfig::default();
87        assert_eq!(cfg.max_attempts, 3);
88        assert_eq!(cfg.base_delay, Duration::from_millis(500));
89        assert_eq!(cfg.max_delay, Duration::from_secs(8));
90    }
91
92    #[tokio::test]
93    async fn succeeds_on_first_attempt() {
94        let mut calls = 0u32;
95        let result = retry_with_backoff(RetryConfig::default(), || {
96            calls += 1;
97            async { Ok::<_, XApiError>(42u32) }
98        })
99        .await;
100        assert_eq!(result.unwrap(), 42);
101        assert_eq!(calls, 1);
102    }
103
104    #[tokio::test]
105    async fn does_not_retry_non_retryable_error() {
106        let mut calls = 0u32;
107        let cfg = RetryConfig {
108            max_attempts: 3,
109            base_delay: Duration::from_millis(1),
110            max_delay: Duration::from_millis(2),
111        };
112        let result = retry_with_backoff(cfg, || {
113            calls += 1;
114            async { Err::<u32, _>(XApiError::AuthExpired) }
115        })
116        .await;
117        assert!(matches!(result, Err(XApiError::AuthExpired)));
118        // Must not retry on non-retryable.
119        assert_eq!(calls, 1);
120    }
121
122    #[tokio::test]
123    async fn retries_retryable_error_up_to_max() {
124        let mut calls = 0u32;
125        let cfg = RetryConfig {
126            max_attempts: 3,
127            base_delay: Duration::from_millis(1),
128            max_delay: Duration::from_millis(2),
129        };
130        let result = retry_with_backoff(cfg, || {
131            calls += 1;
132            async {
133                Err::<u32, _>(XApiError::ScraperTransportUnavailable {
134                    message: "timeout".to_string(),
135                })
136            }
137        })
138        .await;
139        assert!(result.is_err());
140        assert_eq!(calls, 3, "should attempt exactly max_attempts times");
141    }
142
143    #[tokio::test]
144    async fn succeeds_on_retry_after_transient_failure() {
145        use std::sync::{Arc, Mutex};
146        let calls = Arc::new(Mutex::new(0u32));
147        let cfg = RetryConfig {
148            max_attempts: 3,
149            base_delay: Duration::from_millis(1),
150            max_delay: Duration::from_millis(2),
151        };
152        let calls_clone = calls.clone();
153        let result = retry_with_backoff(cfg, move || {
154            let c = calls_clone.clone();
155            async move {
156                let mut n = c.lock().unwrap();
157                *n += 1;
158                if *n < 2 {
159                    Err(XApiError::ScraperTransportUnavailable {
160                        message: "transient".to_string(),
161                    })
162                } else {
163                    Ok(99u32)
164                }
165            }
166        })
167        .await;
168        assert_eq!(result.unwrap(), 99);
169        assert_eq!(*calls.lock().unwrap(), 2);
170    }
171}