Skip to main content

mermaid_model/utils/
retry.rs

1//! Jittered backoff, shared by the one retry ladder.
2//!
3//! This file used to hold a second, weaker ladder: `retry_async` (zero
4//! callers) and `retry_async_if` (one caller, `web_client`, using it at the
5//! wrong scope — the whole request pipeline was inside the retry closure, so a
6//! JSON parse failure cost three attempts and every attempt re-acquired the
7//! download permits). Both are gone; `models::retry::retry_transient_http` is
8//! the single ladder now, and `jitter` is what it shares with nothing else.
9
10/// Apply ±20% jitter to `delay_ms` using real entropy so concurrent clients —
11/// and processes restarting at the same time — don't retry in lockstep (a
12/// thundering herd). `pub(crate)` so the effect-layer retry middleware shares
13/// this single impl rather than duplicating a weaker clock-based one (#87).
14#[must_use]
15pub fn jitter(delay_ms: u64) -> u64 {
16    let span = delay_ms / 5;
17    if span == 0 {
18        return delay_ms;
19    }
20    let mut bytes = [0u8; 8];
21    let entropy = match getrandom::fill(&mut bytes) {
22        Ok(()) => u64::from_le_bytes(bytes),
23        // getrandom shouldn't fail on supported targets; degrade to the
24        // unjittered delay rather than panic.
25        Err(_) => return delay_ms,
26    };
27    let offset = entropy % (2 * span + 1);
28    delay_ms - span + offset
29}
30#[cfg(test)]
31mod tests {
32    use super::*;
33
34    #[test]
35    fn jitter_stays_within_band() {
36        // ±20% band: jitter(1000) ∈ [800, 1200].
37        for _ in 0..100 {
38            let j = jitter(1000);
39            assert!((800..=1200).contains(&j), "jitter out of band: {j}");
40        }
41        // Tiny delays (span 0) pass through unchanged.
42        assert_eq!(jitter(3), 3);
43    }
44}