Skip to main content

brain_db_sdk/
retry.rs

1//! Retry policy and a combinator that applies it to any client operation.
2//!
3//! Retrying is opt-in and composable rather than baked into every verb: wrap a
4//! call in [`with_retry`] and it re-runs the operation while the error is
5//! retryable ([`BrainError::is_retryable`]) and attempts remain, sleeping a
6//! backoff between tries. The backoff honors a server-supplied `retry_after_ms`
7//! when present, else grows exponentially from `base_delay`, capped at
8//! `max_delay`; the actual sleep then gets full jitter (a random point in
9//! `[floor, delay]`, with the server hint as the floor) so clients that failed
10//! together don't retry in lockstep.
11//!
12//! Because every request builder mints a stable `request_id`, re-sending the
13//! *same* request is idempotent on the server (24h idempotency window), so a
14//! retried verb does not double-apply. A server `ResourceExhausted` /
15//! `Unavailable` verdict arrives as a normal ERROR frame, leaving the
16//! connection usable — so those retry in place. A transport drop is reported
17//! retryable too, but on a single non-reconnecting connection it will simply
18//! re-fail fast; transparent reconnect is a later phase.
19
20use std::future::Future;
21use std::time::Duration;
22
23use crate::error::{BrainError, Result};
24
25/// Apply full jitter to a computed backoff: return a uniformly random delay in
26/// `[floor, delay]`. `floor` honors a server-supplied `retry_after` (we never
27/// retry sooner than the server asked); when there is no server floor it is
28/// zero, so the sleep is anywhere in `[0, delay]`. Full jitter spreads a herd
29/// of clients that failed together so they don't re-hit the server in lockstep.
30///
31/// The randomness needs no cryptographic quality — it only has to decorrelate
32/// clients — so we draw from a SplitMix64 seeded from the wall clock rather than
33/// pull in a `rand` dependency.
34fn jittered(delay: Duration, floor: Duration) -> Duration {
35    let floor = floor.min(delay);
36    let span = delay.saturating_sub(floor);
37    let span_nanos = u64::try_from(span.as_nanos()).unwrap_or(u64::MAX);
38    if span_nanos == 0 {
39        return floor;
40    }
41    let seed = std::time::SystemTime::now()
42        .duration_since(std::time::UNIX_EPOCH)
43        // Keeping only the low 64 bits is the intent: these nanos seed a PRNG
44        // and never represent a duration, so the high bits are noise. The mask
45        // makes the `try_from` infallible rather than leaving a silent `as`.
46        .map_or(0x9E37_79B9_7F4A_7C15, |d| {
47            u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0)
48        });
49    // SplitMix64: one mix is plenty to pick a point in the span.
50    let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
51    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
52    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
53    z ^= z >> 31;
54    let extra = z % span_nanos;
55    floor + Duration::from_nanos(extra)
56}
57
58/// What to do after an attempt failed: wait the given backoff then retry, or
59/// give up and surface the error. Produced by [`RetryPolicy::step`] and driven
60/// by both the free [`with_retry`] combinator and the client's per-verb
61/// `*_with_retry` methods.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum RetryStep {
64    /// Retry after an optional sleep.
65    Retry(Option<Duration>),
66    /// Stop and return the error.
67    GiveUp,
68}
69
70/// How many times to retry and how long to wait between tries.
71#[derive(Clone, Debug)]
72pub struct RetryPolicy {
73    /// Total attempts including the first. `1` disables retry.
74    pub max_attempts: u32,
75    /// First backoff; doubles each subsequent attempt.
76    pub base_delay: Duration,
77    /// Upper bound on any single backoff (also caps a server `retry_after`).
78    pub max_delay: Duration,
79}
80
81impl RetryPolicy {
82    /// No retry: a single attempt.
83    #[must_use]
84    pub fn none() -> Self {
85        Self {
86            max_attempts: 1,
87            base_delay: Duration::ZERO,
88            max_delay: Duration::ZERO,
89        }
90    }
91
92    /// An explicit policy.
93    #[must_use]
94    pub fn new(max_attempts: u32, base_delay: Duration, max_delay: Duration) -> Self {
95        Self {
96            max_attempts: max_attempts.max(1),
97            base_delay,
98            max_delay,
99        }
100    }
101
102    /// The backoff to wait *after* `attempt` failed and *before* the next try,
103    /// or `None` when no wait is needed. `attempt` is 1-based. A server-sent
104    /// `retry_after` overrides the exponential schedule (still capped).
105    #[must_use]
106    pub fn backoff(&self, attempt: u32, server_retry_after: Option<Duration>) -> Option<Duration> {
107        if let Some(ra) = server_retry_after {
108            return Some(ra.min(self.max_delay));
109        }
110        if self.base_delay.is_zero() {
111            return None;
112        }
113        // base * 2^(attempt-1), saturating at max_delay.
114        let shift = attempt.saturating_sub(1).min(31);
115        let mult = 1u32 << shift;
116        let delay = self
117            .base_delay
118            .checked_mul(mult)
119            .unwrap_or(self.max_delay)
120            .min(self.max_delay);
121        Some(delay)
122    }
123
124    /// Decide what to do after `attempt` (1-based) produced `err`: retry while
125    /// the error is retryable and attempts remain, else give up. The shared
126    /// decision behind every retry path so they agree.
127    #[must_use]
128    pub fn step(&self, attempt: u32, err: &BrainError) -> RetryStep {
129        if attempt < self.max_attempts && err.is_retryable() {
130            let server_floor = err.retry_after();
131            // `backoff` already folds the server hint in and caps at max_delay;
132            // jitter then spreads the actual sleep across [floor, delay] so a
133            // herd that failed together doesn't retry in lockstep. The server
134            // hint is the floor — we never wake earlier than it asked.
135            let delay = self.backoff(attempt, server_floor).map(|delay| {
136                jittered(
137                    delay,
138                    server_floor.unwrap_or(Duration::ZERO).min(self.max_delay),
139                )
140            });
141            RetryStep::Retry(delay)
142        } else {
143            RetryStep::GiveUp
144        }
145    }
146}
147
148impl Default for RetryPolicy {
149    /// 3 attempts, 100ms base backoff, 5s cap.
150    fn default() -> Self {
151        Self {
152            max_attempts: 3,
153            base_delay: Duration::from_millis(100),
154            max_delay: Duration::from_secs(5),
155        }
156    }
157}
158
159/// Run `op`, retrying per `policy` while the error is retryable and attempts
160/// remain. `op` is re-invoked from scratch each try, so pass a closure that
161/// rebuilds the future (e.g. `|| client.forget(&req)`); the same `req` keeps a
162/// stable `request_id`, making the retry idempotent server-side.
163pub async fn with_retry<T, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T>
164where
165    F: FnMut() -> Fut,
166    Fut: Future<Output = Result<T>>,
167{
168    let mut attempt: u32 = 1;
169    loop {
170        match op().await {
171            Ok(value) => return Ok(value),
172            Err(err) => match policy.step(attempt, &err) {
173                RetryStep::Retry(delay) => {
174                    if let Some(delay) = delay {
175                        tokio::time::sleep(delay).await;
176                    }
177                    attempt += 1;
178                }
179                RetryStep::GiveUp => return Err(err),
180            },
181        }
182    }
183}
184
185/// Convenience: [`with_retry`] with the [`RetryPolicy::default`] schedule.
186pub async fn with_default_retry<T, F, Fut>(op: F) -> Result<T>
187where
188    F: FnMut() -> Fut,
189    Fut: Future<Output = Result<T>>,
190{
191    with_retry(&RetryPolicy::default(), op).await
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::cell::Cell;
198
199    #[test]
200    fn exponential_backoff_doubles_and_caps() {
201        let p = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(1));
202        assert_eq!(p.backoff(1, None), Some(Duration::from_millis(100)));
203        assert_eq!(p.backoff(2, None), Some(Duration::from_millis(200)));
204        assert_eq!(p.backoff(3, None), Some(Duration::from_millis(400)));
205        assert_eq!(p.backoff(4, None), Some(Duration::from_millis(800)));
206        // 1600ms would exceed the 1s cap.
207        assert_eq!(p.backoff(5, None), Some(Duration::from_secs(1)));
208    }
209
210    #[test]
211    fn server_retry_after_overrides_and_is_capped() {
212        let p = RetryPolicy::new(3, Duration::from_millis(100), Duration::from_secs(2));
213        assert_eq!(
214            p.backoff(1, Some(Duration::from_millis(500))),
215            Some(Duration::from_millis(500))
216        );
217        assert_eq!(
218            p.backoff(1, Some(Duration::from_secs(99))),
219            Some(Duration::from_secs(2)),
220            "server hint is capped at max_delay"
221        );
222    }
223
224    #[test]
225    fn jitter_stays_within_bounds_and_honors_the_floor() {
226        let delay = Duration::from_millis(800);
227        // No floor: result lands in [0, delay].
228        for _ in 0..1000 {
229            let j = jittered(delay, Duration::ZERO);
230            assert!(j <= delay, "jitter must not exceed the base delay");
231        }
232        // With a floor: result lands in [floor, delay] and never below the floor.
233        let floor = Duration::from_millis(200);
234        for _ in 0..1000 {
235            let j = jittered(delay, floor);
236            assert!(j >= floor, "jitter must never wake before the server floor");
237            assert!(j <= delay, "jitter must not exceed the base delay");
238        }
239    }
240
241    #[test]
242    fn jitter_with_zero_span_returns_the_floor() {
243        // floor == delay leaves no room to jitter.
244        let d = Duration::from_millis(500);
245        assert_eq!(jittered(d, d), d);
246        // floor clamped to delay when it would exceed it.
247        assert_eq!(jittered(d, Duration::from_secs(99)), d);
248    }
249
250    #[test]
251    fn step_jitters_within_the_scheduled_delay() {
252        let p = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(1));
253        // attempt 4 schedules 800ms; jittered sleep stays within [0, 800ms].
254        for _ in 0..1000 {
255            match p.step(4, &BrainError::Closed) {
256                RetryStep::Retry(Some(d)) => assert!(d <= Duration::from_millis(800)),
257                other => panic!("expected a jittered retry, got {other:?}"),
258            }
259        }
260    }
261
262    #[test]
263    fn none_policy_means_no_backoff() {
264        let p = RetryPolicy::none();
265        assert_eq!(p.max_attempts, 1);
266        assert_eq!(p.backoff(1, None), None);
267    }
268
269    #[tokio::test]
270    async fn retries_until_success_then_stops() {
271        let calls = Cell::new(0u32);
272        // Fail twice (retryable), succeed on the third attempt.
273        let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO);
274        let out: Result<u32> = with_retry(&policy, || {
275            let n = calls.get() + 1;
276            calls.set(n);
277            async move {
278                if n < 3 {
279                    Err(BrainError::Closed)
280                } else {
281                    Ok(n)
282                }
283            }
284        })
285        .await;
286        assert_eq!(out.unwrap(), 3);
287        assert_eq!(calls.get(), 3);
288    }
289
290    #[tokio::test]
291    async fn does_not_retry_a_non_retryable_error() {
292        let calls = Cell::new(0u32);
293        let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO);
294        let out: Result<u32> = with_retry(&policy, || {
295            calls.set(calls.get() + 1);
296            async move { Err(BrainError::Protocol("nope".into())) }
297        })
298        .await;
299        assert!(matches!(out, Err(BrainError::Protocol(_))));
300        assert_eq!(calls.get(), 1, "a non-retryable error must not retry");
301    }
302
303    #[tokio::test]
304    async fn gives_up_after_max_attempts() {
305        let calls = Cell::new(0u32);
306        let policy = RetryPolicy::new(3, Duration::ZERO, Duration::ZERO);
307        let out: Result<u32> = with_retry(&policy, || {
308            calls.set(calls.get() + 1);
309            async move { Err(BrainError::Closed) }
310        })
311        .await;
312        assert!(matches!(out, Err(BrainError::Closed)));
313        assert_eq!(calls.get(), 3, "exactly max_attempts tries");
314    }
315}