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}