Skip to main content

geode_client/
retry.rs

1//! Retry policy and helper (port of Go retry.go).
2
3use std::future::Future;
4
5use tokio::time::{Duration, sleep};
6
7use crate::error::Result;
8
9/// Retry policy: exponential backoff with a maximum number of attempts.
10/// Defaults match the Go reference driver: 3 attempts, 1s initial backoff,
11/// 30s max backoff, multiplier 2.0. No jitter.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct RetryPolicy {
14    /// Maximum number of attempts, including the first call. Values < 1 are
15    /// treated as 1.
16    pub max_attempts: u32,
17    /// Backoff before the first retry.
18    pub initial_backoff: Duration,
19    /// Maximum backoff (clamp ceiling).
20    pub max_backoff: Duration,
21    /// Exponential multiplier per attempt.
22    pub multiplier: f64,
23}
24
25impl Default for RetryPolicy {
26    fn default() -> Self {
27        Self {
28            max_attempts: 3,
29            initial_backoff: Duration::from_secs(1),
30            max_backoff: Duration::from_secs(30),
31            multiplier: 2.0,
32        }
33    }
34}
35
36impl RetryPolicy {
37    /// Compute the backoff for a 1-indexed attempt:
38    /// `initial_backoff * multiplier^(attempt-1)`, clamped to `max_backoff`.
39    pub fn backoff(&self, attempt: u32) -> Duration {
40        let attempt = attempt.max(1);
41        let factor = self.multiplier.powi((attempt - 1) as i32);
42        let nanos = self.initial_backoff.as_nanos() as f64 * factor;
43        if nanos > self.max_backoff.as_nanos() as f64 {
44            return self.max_backoff;
45        }
46        // nanos is finite and within max_backoff (<= u64 nanos range in practice).
47        Duration::from_nanos(nanos as u64)
48    }
49}
50
51/// Run `op` with retries according to `policy`. Retries only errors for which
52/// [`crate::error::Error::is_retryable`] is true. Returns the last error after
53/// exhausting attempts, or immediately on success / non-retryable error.
54pub async fn retry<F, Fut, T>(policy: RetryPolicy, mut op: F) -> Result<T>
55where
56    F: FnMut() -> Fut,
57    Fut: Future<Output = Result<T>>,
58{
59    let max_attempts = policy.max_attempts.max(1);
60    let mut last_err = None;
61
62    for attempt in 1..=max_attempts {
63        match op().await {
64            Ok(v) => return Ok(v),
65            Err(e) => {
66                if !e.is_retryable() {
67                    return Err(e);
68                }
69                if attempt == max_attempts {
70                    last_err = Some(e);
71                    break;
72                }
73                last_err = Some(e);
74                sleep(policy.backoff(attempt)).await;
75            }
76        }
77    }
78
79    Err(last_err.expect("retry loop always records an error before breaking"))
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::error::{Error, Result};
86    use std::sync::Arc;
87    use std::sync::atomic::{AtomicU32, Ordering};
88
89    #[test]
90    fn test_default_policy() {
91        let p = RetryPolicy::default();
92        assert_eq!(p.max_attempts, 3);
93        assert_eq!(p.initial_backoff, Duration::from_secs(1));
94        assert_eq!(p.max_backoff, Duration::from_secs(30));
95        assert_eq!(p.multiplier, 2.0);
96    }
97
98    #[test]
99    fn test_backoff_exponential_and_clamped() {
100        let p = RetryPolicy::default();
101        assert_eq!(p.backoff(1), Duration::from_secs(1));
102        assert_eq!(p.backoff(2), Duration::from_secs(2));
103        assert_eq!(p.backoff(3), Duration::from_secs(4));
104        // clamp test
105        let p2 = RetryPolicy {
106            max_attempts: 5,
107            initial_backoff: Duration::from_secs(1),
108            max_backoff: Duration::from_secs(5),
109            multiplier: 10.0,
110        };
111        assert_eq!(p2.backoff(3), Duration::from_secs(5)); // 1*100 clamped to 5
112    }
113
114    fn fast_policy() -> RetryPolicy {
115        RetryPolicy {
116            max_attempts: 3,
117            initial_backoff: Duration::from_millis(1),
118            max_backoff: Duration::from_millis(2),
119            multiplier: 2.0,
120        }
121    }
122
123    #[tokio::test]
124    async fn test_retry_succeeds_first_attempt() {
125        let calls = Arc::new(AtomicU32::new(0));
126        let c = calls.clone();
127        let r: Result<i32> = retry(fast_policy(), move || {
128            let c = c.clone();
129            async move {
130                c.fetch_add(1, Ordering::SeqCst);
131                Ok(42)
132            }
133        })
134        .await;
135        assert_eq!(r.unwrap(), 42);
136        assert_eq!(calls.load(Ordering::SeqCst), 1);
137    }
138
139    #[tokio::test]
140    async fn test_retry_retries_until_success() {
141        let calls = Arc::new(AtomicU32::new(0));
142        let c = calls.clone();
143        let r: Result<i32> = retry(fast_policy(), move || {
144            let c = c.clone();
145            async move {
146                let n = c.fetch_add(1, Ordering::SeqCst) + 1;
147                if n < 3 {
148                    Err(Error::Connection("transient".into()))
149                } else {
150                    Ok(7)
151                }
152            }
153        })
154        .await;
155        assert_eq!(r.unwrap(), 7);
156        assert_eq!(calls.load(Ordering::SeqCst), 3);
157    }
158
159    #[tokio::test]
160    async fn test_retry_stops_on_non_retryable() {
161        let calls = Arc::new(AtomicU32::new(0));
162        let c = calls.clone();
163        let r: Result<i32> = retry(fast_policy(), move || {
164            let c = c.clone();
165            async move {
166                c.fetch_add(1, Ordering::SeqCst);
167                Err(Error::Query {
168                    code: "42000".into(),
169                    message: "syntax".into(),
170                })
171            }
172        })
173        .await;
174        assert!(r.is_err());
175        assert_eq!(calls.load(Ordering::SeqCst), 1);
176    }
177
178    #[tokio::test]
179    async fn test_retry_exhausts_attempts() {
180        let calls = Arc::new(AtomicU32::new(0));
181        let c = calls.clone();
182        let r: Result<i32> = retry(fast_policy(), move || {
183            let c = c.clone();
184            async move {
185                c.fetch_add(1, Ordering::SeqCst);
186                Err(Error::Timeout)
187            }
188        })
189        .await;
190        assert!(r.is_err());
191        assert_eq!(calls.load(Ordering::SeqCst), 3);
192    }
193}