Skip to main content

faucet_core/
retry.rs

1//! Shared exponential-backoff retry executor for HTTP-style sources.
2//!
3//! Connector authors can wrap any fallible async operation so transient
4//! failures (5xx, connection resets, timeouts — see
5//! [`FaucetError::is_retriable`]) are retried with jittered backoff, while
6//! non-retriable errors fail fast. Used by the XML, GraphQL, and other
7//! sources that talk to flaky HTTP endpoints.
8
9use crate::error::FaucetError;
10use std::future::Future;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::Duration;
13
14/// Hard cap on a single backoff sleep (before jitter). Bounds the exponential so
15/// a large `max_retries`/`attempt` can't produce multi-hour — or, once
16/// `2^attempt` saturates, effectively unbounded — sleeps. The jitter factor
17/// (`< 1.5`) keeps the realised sleep under ~1.5× this.
18const MAX_BACKOFF: Duration = Duration::from_secs(60);
19
20/// Execute `operation` with up to `max_retries` retries on
21/// [retriable](FaucetError::is_retriable) errors, using exponential backoff
22/// (`base_backoff * 2^attempt`) with random jitter. Non-retriable errors
23/// return immediately; `Ok` returns immediately.
24pub async fn execute_with_retry<F, Fut, T>(
25    max_retries: u32,
26    base_backoff: Duration,
27    operation: F,
28) -> Result<T, FaucetError>
29where
30    F: FnMut() -> Fut,
31    Fut: Future<Output = Result<T, FaucetError>>,
32{
33    let policy = crate::resilience::RetryPolicy {
34        // max_attempts is total tries; legacy `max_retries` is retries-after-first.
35        max_attempts: max_retries.saturating_add(1),
36        backoff: crate::resilience::BackoffKind::Exponential,
37        base: base_backoff,
38        max: MAX_BACKOFF,
39        jitter: true,
40        retry_on: crate::resilience::RetryClassSet::default(),
41    };
42    crate::resilience::execute_with_policy(&policy, None, operation).await
43}
44
45/// `base * 2^attempt`, capped at `MAX_BACKOFF` (60s), scaled by a random factor
46/// in `[0.5, 1.5)` to avoid a thundering herd.
47///
48/// Public so a connector with a bespoke retry loop (e.g. one that also honours
49/// `Retry-After`, like the REST source) reuses this one tested, capped,
50/// decorrelated backoff instead of re-implementing jitter — which is exactly
51/// how the range-bias / no-cap bugs crept into a copy.
52pub fn backoff_with_jitter(base: Duration, attempt: u32) -> Duration {
53    let exp = base
54        .saturating_mul(2u32.saturating_pow(attempt))
55        .min(MAX_BACKOFF);
56    let nanos = exp.as_nanos() as u64;
57    Duration::from_nanos((nanos as f64 * pseudo_random_factor()) as u64)
58}
59
60/// Cheap, non-cryptographic random factor in `[0.5, 1.5)`.
61fn pseudo_random_factor() -> f64 {
62    static COUNTER: AtomicU64 = AtomicU64::new(0);
63    let nanos = std::time::SystemTime::now()
64        .duration_since(std::time::UNIX_EPOCH)
65        .unwrap_or_default()
66        .subsec_nanos();
67    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
68    jitter_factor(decorrelate(nanos, counter))
69}
70
71/// Mix the clock's sub-second component with a monotonic per-call counter so two
72/// retries firing in the *same* nanosecond (across tasks/connectors sharing the
73/// process) still draw different jitter — otherwise concurrent retries align and
74/// re-create the very thundering herd the jitter exists to break. Returns a
75/// value in `[0, 1_000_000_000)` for [`jitter_factor`]. splitmix64 finaliser:
76/// fast, non-cryptographic, well-distributed.
77fn decorrelate(nanos: u32, counter: u64) -> u32 {
78    let mut x = (nanos as u64) ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15);
79    x ^= x >> 30;
80    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
81    x ^= x >> 27;
82    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
83    x ^= x >> 31;
84    (x % 1_000_000_000) as u32
85}
86
87/// Map a sub-second nanosecond count (`[0, 1_000_000_000)`) to a jitter factor
88/// in `[0.5, 1.5)`. `subsec_nanos()` is bounded by 1e9, so the divisor must be
89/// 1e9 — not `u32::MAX` (~4.29e9), which would cap the factor at ~0.733 and
90/// make every backoff shorter than documented.
91fn jitter_factor(nanos: u32) -> f64 {
92    0.5 + (nanos as f64 / 1_000_000_000.0)
93}
94
95/// Apply the same `[0.5, 1.5)` decorrelated jitter used by [`backoff_with_jitter`]
96/// to an already-computed delay. Used by the resilience runner, which computes
97/// the base delay from its own [`BackoffKind`](crate::resilience::BackoffKind).
98pub(crate) fn apply_jitter(delay: std::time::Duration) -> std::time::Duration {
99    let nanos = delay.as_nanos() as u64;
100    std::time::Duration::from_nanos((nanos as f64 * pseudo_random_factor()) as u64)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::sync::Arc;
107    use std::sync::atomic::{AtomicU32, Ordering};
108
109    #[tokio::test]
110    async fn returns_immediately_on_success() {
111        let calls = Arc::new(AtomicU32::new(0));
112        let c = calls.clone();
113        let r = execute_with_retry(3, Duration::from_millis(1), move || {
114            c.fetch_add(1, Ordering::SeqCst);
115            async { Ok::<_, FaucetError>(7) }
116        })
117        .await;
118        assert_eq!(r.unwrap(), 7);
119        assert_eq!(calls.load(Ordering::SeqCst), 1);
120    }
121
122    #[tokio::test]
123    async fn retries_then_succeeds_on_transient_5xx() {
124        let calls = Arc::new(AtomicU32::new(0));
125        let c = calls.clone();
126        let r = execute_with_retry(3, Duration::from_millis(1), move || {
127            let n = c.fetch_add(1, Ordering::SeqCst);
128            async move {
129                if n < 2 {
130                    Err::<i32, _>(FaucetError::HttpStatus {
131                        status: 503,
132                        url: "http://t".into(),
133                        body: "x".into(),
134                    })
135                } else {
136                    Ok(42)
137                }
138            }
139        })
140        .await;
141        assert_eq!(r.unwrap(), 42);
142        assert_eq!(calls.load(Ordering::SeqCst), 3);
143    }
144
145    #[test]
146    fn jitter_factor_spans_documented_half_to_one_and_a_half_range() {
147        // The factor must span [0.5, 1.5): 0 nanos → 0.5, the midpoint → 1.0,
148        // and the maximum sub-second value → just under 1.5. A `u32::MAX`
149        // divisor caps the top at ~0.733, so backoff is always too short.
150        assert_eq!(jitter_factor(0), 0.5);
151        let mid = jitter_factor(500_000_000);
152        assert!((mid - 1.0).abs() < 1e-6, "midpoint factor was {mid}");
153        let hi = jitter_factor(999_999_999);
154        assert!(
155            (1.4..1.5).contains(&hi),
156            "factor at max sub-second nanos was {hi}, expected ~1.5"
157        );
158    }
159
160    #[test]
161    fn backoff_is_capped_for_large_attempt() {
162        // Without a cap, `base * 2^attempt` saturates and the sleep becomes
163        // effectively unbounded (multi-century). It must stay under
164        // MAX_BACKOFF * max-jitter (<1.5) → < 90s for a 60s cap.
165        let d = backoff_with_jitter(Duration::from_secs(1), 60);
166        assert!(d < Duration::from_secs(90), "backoff not capped: {d:?}");
167        // …and never collapses to zero for a non-zero base.
168        assert!(
169            d >= Duration::from_secs(30),
170            "backoff unexpectedly tiny: {d:?}"
171        );
172    }
173
174    #[test]
175    fn decorrelate_diverges_for_same_nanos_concurrent_calls() {
176        // Two retries observing the *same* clock nanosecond but different
177        // per-call counters must draw different jitter, or concurrent retries
178        // re-align into the thundering herd the jitter exists to break.
179        let a = decorrelate(123_456_789, 0);
180        let b = decorrelate(123_456_789, 1);
181        let c = decorrelate(123_456_789, 2);
182        assert_ne!(a, b);
183        assert_ne!(b, c);
184        assert_ne!(a, c);
185        for v in [a, b, c] {
186            assert!(
187                v < 1_000_000_000,
188                "decorrelate out of jitter_factor range: {v}"
189            );
190        }
191    }
192
193    #[tokio::test]
194    async fn non_retriable_fails_immediately() {
195        let calls = Arc::new(AtomicU32::new(0));
196        let c = calls.clone();
197        let r = execute_with_retry(3, Duration::from_millis(1), move || {
198            c.fetch_add(1, Ordering::SeqCst);
199            async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
200        })
201        .await;
202        assert!(r.is_err());
203        assert_eq!(calls.load(Ordering::SeqCst), 1);
204    }
205}