Skip to main content

geode_client/
retry.rs

1//! Retry policy with exponential backoff for transient errors.
2//!
3//! Mirrors the Go reference client's `RetryPolicy`/`Retry`: a closure is
4//! re-invoked up to [`RetryPolicy::max_attempts`] times, but only when the
5//! returned error is [`Error::is_retryable`]. Non-retryable errors are
6//! returned immediately.
7
8use std::time::Duration;
9
10use crate::error::{Error, Result};
11
12/// Default maximum number of attempts (including the initial call).
13const DEFAULT_MAX_ATTEMPTS: u32 = 3;
14/// Default delay before the first retry.
15const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
16/// Default upper bound on the backoff duration.
17const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
18/// Default exponential backoff multiplier.
19const DEFAULT_MULTIPLIER: f64 = 2.0;
20
21/// Configures retry behavior for transient (retryable) errors.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct RetryPolicy {
24    /// Maximum number of attempts, including the initial call. Values less
25    /// than 1 are treated as 1.
26    pub max_attempts: u32,
27    /// Delay before the first retry.
28    pub initial_backoff: Duration,
29    /// Maximum backoff duration; the exponential backoff is clamped to this.
30    pub max_backoff: Duration,
31    /// Exponential backoff multiplier applied on each retry.
32    pub multiplier: f64,
33}
34
35impl Default for RetryPolicy {
36    /// Returns a policy with sensible defaults: 3 max attempts, 1s initial
37    /// backoff, 30s max backoff, and a 2.0 multiplier.
38    fn default() -> Self {
39        Self {
40            max_attempts: DEFAULT_MAX_ATTEMPTS,
41            initial_backoff: DEFAULT_INITIAL_BACKOFF,
42            max_backoff: DEFAULT_MAX_BACKOFF,
43            multiplier: DEFAULT_MULTIPLIER,
44        }
45    }
46}
47
48impl RetryPolicy {
49    /// Calculate the backoff duration for the given 1-indexed attempt,
50    /// clamped to [`RetryPolicy::max_backoff`].
51    fn backoff(&self, attempt: u32) -> Duration {
52        let attempt = attempt.max(1);
53        // initial_backoff * multiplier^(attempt-1)
54        let factor = self.multiplier.powi((attempt - 1) as i32);
55        let secs = self.initial_backoff.as_secs_f64() * factor;
56        let max_secs = self.max_backoff.as_secs_f64();
57        // A non-finite or over-cap result clamps to max_backoff. A negative
58        // result (possible if a caller sets a negative `multiplier`) clamps to
59        // zero so `Duration::from_secs_f64` never panics on a negative value.
60        if !secs.is_finite() || secs >= max_secs {
61            return self.max_backoff;
62        }
63        if secs <= 0.0 {
64            return Duration::ZERO;
65        }
66        Duration::from_secs_f64(secs)
67    }
68}
69
70/// Execute `f` up to `policy.max_attempts` times, retrying only when the
71/// returned error is [`Error::is_retryable`].
72///
73/// Non-retryable errors (e.g. authentication, validation, syntax) are
74/// returned immediately without further attempts. Between attempts the
75/// future sleeps for an exponentially increasing backoff capped at
76/// [`RetryPolicy::max_backoff`].
77///
78/// # Example
79///
80/// ```no_run
81/// use geode_client::{retry, RetryPolicy, Error};
82///
83/// # async fn example() -> geode_client::Result<()> {
84/// let result = retry(RetryPolicy::default(), || async {
85///     // ... perform a fallible operation ...
86///     Ok::<i32, Error>(42)
87/// })
88/// .await?;
89/// assert_eq!(result, 42);
90/// # Ok(())
91/// # }
92/// ```
93pub async fn retry<F, Fut, T>(policy: RetryPolicy, mut f: F) -> Result<T>
94where
95    F: FnMut() -> Fut,
96    Fut: std::future::Future<Output = Result<T>>,
97{
98    let max_attempts = policy.max_attempts.max(1);
99    let mut last_err: Option<Error> = None;
100
101    for attempt in 1..=max_attempts {
102        match f().await {
103            Ok(value) => return Ok(value),
104            Err(err) => {
105                // Only retry errors that are explicitly retryable.
106                if !err.is_retryable() {
107                    return Err(err);
108                }
109                last_err = Some(err);
110                // No more attempts remaining.
111                if attempt == max_attempts {
112                    break;
113                }
114                let delay = policy.backoff(attempt);
115                tokio::time::sleep(delay).await;
116            }
117        }
118    }
119
120    Err(last_err.unwrap_or_else(|| Error::Other("retry: no attempts made".to_string())))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::cell::Cell;
127
128    fn fast_policy(max_attempts: u32) -> RetryPolicy {
129        RetryPolicy {
130            max_attempts,
131            initial_backoff: Duration::from_millis(0),
132            max_backoff: Duration::from_millis(0),
133            multiplier: 2.0,
134        }
135    }
136
137    #[tokio::test]
138    async fn test_succeeds_first_try() {
139        let calls = Cell::new(0);
140        let result: Result<i32> = retry(fast_policy(3), || {
141            calls.set(calls.get() + 1);
142            async { Ok(7) }
143        })
144        .await;
145        assert_eq!(result.unwrap(), 7);
146        assert_eq!(calls.get(), 1);
147    }
148
149    #[tokio::test]
150    async fn test_retries_then_succeeds() {
151        let calls = Cell::new(0);
152        let result: Result<i32> = retry(fast_policy(3), || {
153            let n = calls.get() + 1;
154            calls.set(n);
155            async move {
156                if n < 3 {
157                    // Retryable error (connection).
158                    Err(Error::connection("transient"))
159                } else {
160                    Ok(99)
161                }
162            }
163        })
164        .await;
165        assert_eq!(result.unwrap(), 99);
166        assert_eq!(calls.get(), 3);
167    }
168
169    #[tokio::test]
170    async fn test_gives_up_after_max_attempts() {
171        let calls = Cell::new(0);
172        let result: Result<i32> = retry(fast_policy(3), || {
173            calls.set(calls.get() + 1);
174            async { Err(Error::timeout()) }
175        })
176        .await;
177        assert!(result.is_err());
178        assert_eq!(calls.get(), 3, "should attempt exactly max_attempts times");
179    }
180
181    #[tokio::test]
182    async fn test_does_not_retry_non_retryable_auth() {
183        let calls = Cell::new(0);
184        let result: Result<i32> = retry(fast_policy(5), || {
185            calls.set(calls.get() + 1);
186            async { Err(Error::auth("bad credentials")) }
187        })
188        .await;
189        assert!(result.is_err());
190        assert_eq!(calls.get(), 1, "non-retryable error must not be retried");
191    }
192
193    #[tokio::test]
194    async fn test_does_not_retry_non_retryable_validation() {
195        let calls = Cell::new(0);
196        let result: Result<i32> = retry(fast_policy(5), || {
197            calls.set(calls.get() + 1);
198            async { Err(Error::validation("bad input")) }
199        })
200        .await;
201        assert!(result.is_err());
202        assert_eq!(calls.get(), 1);
203    }
204
205    #[tokio::test]
206    async fn test_max_attempts_zero_treated_as_one() {
207        let calls = Cell::new(0);
208        let result: Result<i32> = retry(fast_policy(0), || {
209            calls.set(calls.get() + 1);
210            async { Err(Error::connection("transient")) }
211        })
212        .await;
213        assert!(result.is_err());
214        assert_eq!(calls.get(), 1);
215    }
216
217    #[test]
218    fn test_default_policy_values() {
219        let p = RetryPolicy::default();
220        assert_eq!(p.max_attempts, 3);
221        assert_eq!(p.initial_backoff, Duration::from_secs(1));
222        assert_eq!(p.max_backoff, Duration::from_secs(30));
223        assert_eq!(p.multiplier, 2.0);
224    }
225
226    #[test]
227    fn test_backoff_exponential_and_clamped() {
228        let p = RetryPolicy {
229            max_attempts: 10,
230            initial_backoff: Duration::from_secs(1),
231            max_backoff: Duration::from_secs(10),
232            multiplier: 2.0,
233        };
234        assert_eq!(p.backoff(1), Duration::from_secs(1)); // 1 * 2^0
235        assert_eq!(p.backoff(2), Duration::from_secs(2)); // 1 * 2^1
236        assert_eq!(p.backoff(3), Duration::from_secs(4)); // 1 * 2^2
237        assert_eq!(p.backoff(4), Duration::from_secs(8)); // 1 * 2^3
238        // 1 * 2^4 = 16 > 10, clamped.
239        assert_eq!(p.backoff(5), Duration::from_secs(10));
240        assert_eq!(p.backoff(100), Duration::from_secs(10));
241    }
242
243    #[test]
244    fn test_backoff_negative_multiplier_does_not_panic() {
245        // A negative multiplier yields a negative factor on even attempts;
246        // backoff must clamp to zero rather than panic in Duration::from_secs_f64.
247        let p = RetryPolicy {
248            max_attempts: 5,
249            initial_backoff: Duration::from_secs(1),
250            max_backoff: Duration::from_secs(10),
251            multiplier: -2.0,
252        };
253        // attempt 2 => 1 * (-2)^1 = -2.0 => clamps to zero, no panic.
254        assert_eq!(p.backoff(2), Duration::ZERO);
255        // attempt 1 => 1 * (-2)^0 = 1.0 (positive).
256        assert_eq!(p.backoff(1), Duration::from_secs(1));
257        // exercise a range to be safe.
258        for attempt in 1..=5 {
259            let _ = p.backoff(attempt);
260        }
261    }
262}