Skip to main content

mermaid_cli/utils/
retry.rs

1use anyhow::Result;
2use std::time::Duration;
3use tracing::debug;
4
5/// Retry configuration
6pub struct RetryConfig {
7    pub max_attempts: usize,
8    pub initial_delay_ms: u64,
9    pub max_delay_ms: u64,
10    pub backoff_multiplier: f64,
11}
12
13impl Default for RetryConfig {
14    fn default() -> Self {
15        Self {
16            max_attempts: 3,
17            initial_delay_ms: 100,
18            max_delay_ms: 10_000,
19            backoff_multiplier: 2.0,
20        }
21    }
22}
23
24/// Retry an async operation with exponential backoff, retrying on ANY error.
25/// For selective retries (skip terminal errors like HTTP 4xx) use
26/// [`retry_async_if`].
27pub async fn retry_async<F, Fut, T>(operation: F, config: &RetryConfig) -> Result<T>
28where
29    F: Fn() -> Fut,
30    Fut: std::future::Future<Output = Result<T>>,
31{
32    retry_async_if(operation, config, |_| true).await
33}
34
35/// Retry an async operation with exponential backoff, but only while
36/// `is_retryable` returns true for the error. A terminal error (e.g. an HTTP
37/// 4xx on a non-idempotent POST) is surfaced immediately instead of being
38/// retried `max_attempts` times (#85).
39pub async fn retry_async_if<F, Fut, T, P>(
40    operation: F,
41    config: &RetryConfig,
42    is_retryable: P,
43) -> Result<T>
44where
45    F: Fn() -> Fut,
46    Fut: std::future::Future<Output = Result<T>>,
47    P: Fn(&anyhow::Error) -> bool,
48{
49    let mut attempt = 0;
50    let mut delay_ms = config.initial_delay_ms;
51
52    loop {
53        attempt += 1;
54
55        match operation().await {
56            Ok(result) => return Ok(result),
57            // Terminal error, or attempts exhausted: stop retrying.
58            Err(e) if attempt >= config.max_attempts || !is_retryable(&e) => {
59                if attempt >= config.max_attempts {
60                    return Err(anyhow::anyhow!(
61                        "Operation failed after {} attempts: {}",
62                        config.max_attempts,
63                        e
64                    ));
65                }
66                // Non-retryable: surface the original error unwrapped.
67                return Err(e);
68            },
69            Err(e) => {
70                debug!(
71                    attempt = attempt,
72                    max_attempts = config.max_attempts,
73                    delay_ms = delay_ms,
74                    "Retry attempt failed: {}",
75                    e
76                );
77
78                // Sleep with jittered exponential backoff (the jitter de-syncs
79                // concurrent clients so they don't retry in lockstep).
80                tokio::time::sleep(Duration::from_millis(jitter(delay_ms))).await;
81
82                // Calculate next delay
83                delay_ms = ((delay_ms as f64) * config.backoff_multiplier) as u64;
84                delay_ms = delay_ms.min(config.max_delay_ms);
85            },
86        }
87    }
88}
89
90/// Apply ±20% jitter to `delay_ms` using real entropy so concurrent clients —
91/// and processes restarting at the same time — don't retry in lockstep (a
92/// thundering herd). `pub(crate)` so the effect-layer retry middleware shares
93/// this single impl rather than duplicating a weaker clock-based one (#87).
94pub(crate) fn jitter(delay_ms: u64) -> u64 {
95    let span = delay_ms / 5;
96    if span == 0 {
97        return delay_ms;
98    }
99    let mut bytes = [0u8; 8];
100    let entropy = match getrandom::fill(&mut bytes) {
101        Ok(()) => u64::from_le_bytes(bytes),
102        // getrandom shouldn't fail on supported targets; degrade to the
103        // unjittered delay rather than panic.
104        Err(_) => return delay_ms,
105    };
106    let offset = entropy % (2 * span + 1);
107    delay_ms - span + offset
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::sync::Arc;
114    use std::sync::atomic::{AtomicUsize, Ordering};
115
116    #[tokio::test]
117    async fn test_retry_async_success_on_first_try() {
118        let config = RetryConfig::default();
119        let call_count = Arc::new(AtomicUsize::new(0));
120        let call_count_clone = Arc::clone(&call_count);
121
122        let result = retry_async(
123            move || {
124                let count = Arc::clone(&call_count_clone);
125                async move {
126                    count.fetch_add(1, Ordering::SeqCst);
127                    Ok::<_, anyhow::Error>(42)
128                }
129            },
130            &config,
131        )
132        .await;
133
134        assert!(result.is_ok());
135        assert_eq!(result.unwrap(), 42);
136        assert_eq!(call_count.load(Ordering::SeqCst), 1);
137    }
138
139    #[tokio::test]
140    async fn test_retry_async_success_on_second_try() {
141        let config = RetryConfig {
142            max_attempts: 3,
143            initial_delay_ms: 10,
144            ..Default::default()
145        };
146        let call_count = Arc::new(AtomicUsize::new(0));
147        let call_count_clone = Arc::clone(&call_count);
148
149        let result = retry_async(
150            move || {
151                let count = Arc::clone(&call_count_clone);
152                async move {
153                    let current = count.fetch_add(1, Ordering::SeqCst) + 1;
154                    if current < 2 {
155                        Err(anyhow::anyhow!("Temporary error"))
156                    } else {
157                        Ok(42)
158                    }
159                }
160            },
161            &config,
162        )
163        .await;
164
165        assert!(result.is_ok());
166        assert_eq!(result.unwrap(), 42);
167        assert_eq!(call_count.load(Ordering::SeqCst), 2);
168    }
169
170    #[tokio::test]
171    async fn test_retry_async_fails_after_max_attempts() {
172        let config = RetryConfig {
173            max_attempts: 3,
174            initial_delay_ms: 10,
175            ..Default::default()
176        };
177        let call_count = Arc::new(AtomicUsize::new(0));
178        let call_count_clone = Arc::clone(&call_count);
179
180        let result = retry_async(
181            move || {
182                let count = Arc::clone(&call_count_clone);
183                async move {
184                    count.fetch_add(1, Ordering::SeqCst);
185                    Err::<i32, _>(anyhow::anyhow!("Persistent error"))
186                }
187            },
188            &config,
189        )
190        .await;
191
192        assert!(result.is_err());
193        assert_eq!(call_count.load(Ordering::SeqCst), 3);
194    }
195
196    #[tokio::test]
197    async fn retry_async_if_skips_nonretryable_errors() {
198        // #85: a non-retryable error returns immediately (one attempt), not
199        // after max_attempts.
200        let config = RetryConfig {
201            max_attempts: 5,
202            initial_delay_ms: 1,
203            ..Default::default()
204        };
205        let calls = Arc::new(AtomicUsize::new(0));
206        let cc = Arc::clone(&calls);
207        let result: Result<i32> = retry_async_if(
208            move || {
209                let c = Arc::clone(&cc);
210                async move {
211                    c.fetch_add(1, Ordering::SeqCst);
212                    Err(anyhow::anyhow!("terminal"))
213                }
214            },
215            &config,
216            |_| false,
217        )
218        .await;
219        assert!(result.is_err());
220        assert_eq!(calls.load(Ordering::SeqCst), 1);
221    }
222
223    #[test]
224    fn jitter_stays_within_band() {
225        // ±20% band: jitter(1000) ∈ [800, 1200].
226        for _ in 0..100 {
227            let j = jitter(1000);
228            assert!((800..=1200).contains(&j), "jitter out of band: {j}");
229        }
230        // Tiny delays (span 0) pass through unchanged.
231        assert_eq!(jitter(3), 3);
232    }
233}