tuitbot_core/x_api/
retry.rs1use std::time::Duration;
10
11use rand::Rng;
12
13use crate::error::XApiError;
14
15#[derive(Debug, Clone, Copy)]
17pub struct RetryConfig {
18 pub max_attempts: u32,
20 pub base_delay: Duration,
22 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
36pub 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 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 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}