dwara_core/resilience/
retries.rs

1//! Upstream retries: resolved parameters, retry budget, backoff with full
2//! jitter (DW-014, feature analysis 4.11).
3//!
4//! # What retries are
5//!
6//! All knobs live on the UPSTREAM (`upstreams[].retries`); there is no
7//! per-route retry configuration in v1. [`RetryParams`] is the resolved,
8//! validated form carried by each `UpstreamHandle`;
9//! [`RetryConfig`] is the serde schema form (defaults applied there).
10//!
11//! # Retry budget
12//!
13//! Each upstream owns one [`RetryBudget`]: a rolling 10-second window of
14//! `(timestamp, is_retry)` events. A request may be retried only while the
15//! invariant `(retries + 1) * 100 <= percent * totals` holds inside the
16//! window, where `totals` counts proxied requests to that upstream (one
17//! record per request, not per attempt) and `retries` counts retry
18//! attempts. The check-and-reserve is a single lock-protected step, so the
19//! invariant is never transiently violated by concurrent requests; the
20//! conservative form (charging the retry BEFORE it happens) means a fresh
21//! window with little volume grants few or no retries rather than allowing
22//! a burst — a deliberately safe bias for a gateway protecting an already
23//! struggling upstream. Budget state is carried across config reloads
24//! (keyed by upstream name, exactly like balancer state).
25//!
26//! # Backoff
27//!
28//! Nominal delay before retry n (n = 1 for the first retry) is
29//! `min(base * 2^(n-1), cap)` (saturating). The actual sleep applies FULL
30//! JITTER, AWS style: a uniform random duration in `[0, nominal]`
31//! (`backoff_with_full_jitter`). Full jitter avoids the thundering-herd
32//! synchronization of decorrelated jitter while still bounding the worst
33//! case by the nominal delay. Randomness is a process-local xorshift64*
34//! seed under a CAS loop — the same pattern as the load balancer's
35//! `random-2` draws, no `rand` dependency.
36//!
37//! # Total latency
38//!
39//! The cross-attempt total deadline (`total_deadline`, REL-01) caps the
40//! wall-clock time from the first attempt to the last, INCLUDING backoff
41//! delays between attempts. When set, a retry whose backoff would cross
42//! the deadline is aborted and the last response/error is returned to the
43//! client; the per-attempt `read_ms` timeout still bounds each individual
44//! attempt. Absent (the default) leaves the cross-attempt budget
45//! unbounded — worst case the retry loop adds up to
46//! `attempts * (read_ms + backoff_cap_ms)` of latency to a single request
47//! (each attempt pays its own read timeout plus at most the backoff cap
48//! before it). Standard in Envoy and NGINX.
49
50use std::collections::VecDeque;
51// DW-025: loom-model-checked Mutex under the `loom` dev feature.
52#[cfg(feature = "loom")]
53use loom::sync::Mutex;
54#[cfg(not(feature = "loom"))]
55use parking_lot::Mutex;
56// OnceLock seeds the jitter RNG in production code; it stays std.
57use std::sync::OnceLock;
58use std::time::Duration;
59
60use crate::config::RetryConfig;
61
62/// Rolling window the retry budget accounts over (milliseconds).
63pub const RETRY_BUDGET_WINDOW_MS: u64 = 10_000;
64/// Default retry statuses (`502, 503, 504`).
65pub const DEFAULT_RETRY_STATUSES: [u16; 3] = [502, 503, 504];
66
67/// Resolved (validated) hedge parameters for one upstream (DW-063).
68/// `hedge_after` is `None` when hedging is disabled (no `hedge` block or
69/// `hedge_after_ms == 0`). When enabled, a speculative duplicate is sent
70/// after `hedge_after` without a response; at most `hedge_max` copies
71/// per request.
72#[derive(Debug, Clone, PartialEq)]
73pub struct HedgeParams {
74    /// Tail-latency threshold before a hedge copy is sent.
75    pub hedge_after: Duration,
76    /// Maximum speculative copies per request.
77    pub hedge_max: u32,
78}
79
80impl Default for HedgeParams {
81    fn default() -> Self {
82        HedgeParams {
83            hedge_after: Duration::ZERO,
84            hedge_max: 1,
85        }
86    }
87}
88
89impl HedgeParams {
90    /// Whether hedging is enabled for this upstream.
91    pub fn enabled(&self) -> bool {
92        self.hedge_after > Duration::ZERO
93    }
94
95    /// Resolve from the config form; `None` yields the disabled default.
96    pub fn from_config(cfg: Option<&crate::config::HedgeConfig>) -> Self {
97        match cfg {
98            None => HedgeParams::default(),
99            Some(c) => HedgeParams {
100                hedge_after: Duration::from_millis(c.hedge_after_ms),
101                hedge_max: c.hedge_max,
102            },
103        }
104    }
105}
106
107/// Resolved (validated) retry parameters for one upstream. `attempts == 0`
108/// disables retries entirely — the single-attempt path then performs no
109/// body buffering and no extra per-request work (the budget denominator is
110/// still recorded: the window counts all proxied traffic).
111#[derive(Debug, Clone, PartialEq)]
112pub struct RetryParams {
113    /// Maximum retries beyond the first attempt.
114    pub attempts: u32,
115    /// Whether POST requests may be retried (opt-in). Governs POST only:
116    /// other non-idempotent methods (DELETE, PATCH, ...) are never retried.
117    pub retry_post: bool,
118    /// Backoff base (milliseconds).
119    pub backoff_base_ms: u64,
120    /// Backoff ceiling (milliseconds).
121    pub backoff_cap_ms: u64,
122    /// Response statuses that trigger a retry.
123    pub retry_statuses: Vec<u16>,
124    /// Whether transport errors and per-attempt read timeouts trigger a
125    /// retry.
126    pub retry_transport: bool,
127    /// Retry budget percentage in (0, 100].
128    pub budget_percent: u32,
129    /// Request-body buffering cap in bytes.
130    pub buffer_max_bytes: u64,
131    /// Request hedging parameters (DW-063).
132    pub hedge: HedgeParams,
133    /// Cross-attempt total retry deadline (REL-01): caps the wall-clock
134    /// time from the first attempt to the last, INCLUDING backoff delays.
135    /// `None` leaves the cross-attempt budget unbounded (the default).
136    pub total_deadline: Option<Duration>,
137    /// Streaming failover first-frame buffer cap in bytes (REL-05, #216).
138    /// When > 0, the proxy buffers up to this many bytes of the upstream
139    /// response body before forwarding any bytes to the client, so a
140    /// mid-body failure before the buffer fills can still trigger a
141    /// retry to a different endpoint. Default 0 = no first-frame
142    /// buffering (v1 behavior).
143    pub buffer_first_frame_bytes: u64,
144}
145
146impl Default for RetryParams {
147    fn default() -> Self {
148        RetryParams {
149            attempts: 0,
150            retry_post: false,
151            backoff_base_ms: 25,
152            backoff_cap_ms: 250,
153            retry_statuses: DEFAULT_RETRY_STATUSES.to_vec(),
154            retry_transport: true,
155            budget_percent: 10,
156            buffer_max_bytes: 0,
157            hedge: HedgeParams::default(),
158            total_deadline: None,
159            buffer_first_frame_bytes: 0,
160        }
161    }
162}
163
164impl RetryParams {
165    /// Resolve from the config form; `None` yields the disabled default
166    /// (serde has already applied per-field defaults for a present block).
167    pub fn from_config(cfg: Option<&RetryConfig>) -> Self {
168        match cfg {
169            None => RetryParams::default(),
170            Some(c) => RetryParams {
171                attempts: c.attempts,
172                retry_post: c.retry_post,
173                backoff_base_ms: c.backoff_base_ms,
174                backoff_cap_ms: c.backoff_cap_ms,
175                retry_statuses: c.retry_statuses.clone(),
176                retry_transport: c.retry_transport,
177                budget_percent: c.budget_percent,
178                buffer_max_bytes: c.buffer_max_bytes,
179                hedge: HedgeParams::from_config(c.hedge.as_ref()),
180                total_deadline: c.total_deadline_ms.map(Duration::from_millis),
181                buffer_first_frame_bytes: c.buffer_first_frame_bytes,
182            },
183        }
184    }
185
186    /// Whether a response status triggers a retry under these parameters.
187    pub fn retries_status(&self, status: u16) -> bool {
188        self.retry_statuses.contains(&status)
189    }
190}
191
192/// Unix-epoch milliseconds (the same clock convention as passive health).
193fn system_now_ms() -> u64 {
194    std::time::SystemTime::now()
195        .duration_since(std::time::UNIX_EPOCH)
196        .map(|d| d.as_millis() as u64)
197        .unwrap_or(0)
198}
199
200/// Per-upstream rolling-window retry budget. Share via `Arc` from the
201/// upstream handle; the lock is taken once per proxied request (record)
202/// and once per retry decision (reserve) — never per attempt frame.
203pub struct RetryBudget {
204    window_ms: u64,
205    now_ms: fn() -> u64,
206    events: Mutex<VecDeque<(u64, bool)>>,
207}
208
209impl Default for RetryBudget {
210    fn default() -> Self {
211        RetryBudget::new()
212    }
213}
214
215impl RetryBudget {
216    /// New budget on the system clock with the standard window.
217    pub fn new() -> Self {
218        RetryBudget::with_clock(RETRY_BUDGET_WINDOW_MS, system_now_ms)
219    }
220
221    /// New budget with a caller-supplied millisecond clock (Unix epoch).
222    /// Intended for tests; production keeps the system clock.
223    pub fn with_clock(window_ms: u64, now_ms: fn() -> u64) -> Self {
224        RetryBudget {
225            window_ms,
226            now_ms,
227            events: Mutex::new(VecDeque::new()),
228        }
229    }
230
231    fn prune_locked(&self, events: &mut VecDeque<(u64, bool)>, now: u64) {
232        while events
233            .front()
234            .is_some_and(|&(t, _)| now.saturating_sub(t) >= self.window_ms)
235        {
236            events.pop_front();
237        }
238    }
239
240    /// Record one proxied request (the denominator of the budget ratio).
241    /// Called once per request, never per attempt.
242    pub fn record_request(&self) {
243        let now = (self.now_ms)();
244        let mut events = self.events.lock();
245        self.prune_locked(&mut events, now);
246        events.push_back((now, false));
247    }
248
249    /// Whether a retry may be issued under `percent`, charging it
250    /// atomically (check + record under one lock acquisition) so the
251    /// in-window invariant `retries * 100 <= percent * requests` is never
252    /// exceeded, even under concurrent retries. `requests` counts the
253    /// ORIGINAL proxied requests only (retry events are excluded from the
254    /// denominator — the budget bounds retries as a share of real
255    /// traffic). The candidate retry is charged BEFORE it runs: allowing
256    /// requires `(retries + 1) * 100 <= percent * requests` to hold
257    /// already, so a fresh window with little volume grants few or no
258    /// retries rather than a burst.
259    pub fn try_reserve_retry(&self, percent: u32) -> bool {
260        let now = (self.now_ms)();
261        let mut events = self.events.lock();
262        self.prune_locked(&mut events, now);
263        let requests = events.iter().filter(|(_, r)| !*r).count() as u64;
264        let retries = events.len() as u64 - requests;
265        if (retries + 1) * 100 <= u64::from(percent) * requests {
266            events.push_back((now, true));
267            true
268        } else {
269            false
270        }
271    }
272
273    /// In-window totals (observability/tests).
274    pub fn totals(&self) -> usize {
275        let now = (self.now_ms)();
276        let mut events = self.events.lock();
277        self.prune_locked(&mut events, now);
278        events.len()
279    }
280
281    /// In-window retries (observability/tests).
282    pub fn retries(&self) -> usize {
283        let now = (self.now_ms)();
284        let mut events = self.events.lock();
285        self.prune_locked(&mut events, now);
286        events.iter().filter(|(_, r)| *r).count()
287    }
288}
289
290impl std::fmt::Debug for RetryBudget {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.debug_struct("RetryBudget")
293            .field("window_ms", &self.window_ms)
294            .field("totals", &self.totals())
295            .field("retries", &self.retries())
296            .finish()
297    }
298}
299
300fn xorshift(x: u64) -> u64 {
301    let mut x = x.wrapping_add(0x9E3779B97F4A7C15);
302    x ^= x >> 12;
303    x ^= x << 25;
304    x ^= x >> 27;
305    x.wrapping_mul(0x2545F4914F6CDD1D)
306}
307
308fn jitter_rng() -> u64 {
309    static SEED: OnceLock<u64> = OnceLock::new();
310    static STATE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
311    let seed = *SEED.get_or_init(|| {
312        std::time::SystemTime::now()
313            .duration_since(std::time::UNIX_EPOCH)
314            .map(|d| d.as_nanos() as u64)
315            .unwrap_or(0x853c49e6748fea9b)
316            ^ (std::process::id() as u64)
317    });
318    loop {
319        let x = STATE.load(std::sync::atomic::Ordering::Relaxed);
320        // A zero state would freeze xorshift at 0; fold the seed in so the
321        // first draw (and every wrap) is nonzero.
322        let nx = xorshift(x ^ seed);
323        if STATE
324            .compare_exchange(
325                x,
326                nx,
327                std::sync::atomic::Ordering::Relaxed,
328                std::sync::atomic::Ordering::Relaxed,
329            )
330            .is_ok()
331        {
332            return nx;
333        }
334    }
335}
336
337/// Nominal (pre-jitter) backoff for retry number `retry` (1 = the first
338/// retry): `min(base * 2^(retry-1), cap)`, saturating on both the shift
339/// and the multiply.
340pub fn nominal_backoff_ms(base_ms: u64, cap_ms: u64, retry: u32) -> u64 {
341    let shift = (retry - 1).min(63);
342    base_ms
343        .saturating_mul(1u64 << shift)
344        .min(cap_ms.max(base_ms))
345}
346
347/// Full-jitter sleep duration for retry number `retry` (1 = the first
348/// retry): a uniform draw in `[0, nominal_backoff_ms(base, cap, retry)]`
349/// (AWS "Exponential Backoff and Jitter" full jitter). Pure in `rand` so
350/// tests can pin bounds; the production draw is [`jitter_delay`].
351pub fn backoff_with_full_jitter(base_ms: u64, cap_ms: u64, retry: u32, rand: u64) -> Duration {
352    let nominal = nominal_backoff_ms(base_ms, cap_ms, retry);
353    Duration::from_millis(rand % (nominal + 1))
354}
355
356/// [`backoff_with_full_jitter`] drawing from the process-local rng.
357pub fn jitter_delay(base_ms: u64, cap_ms: u64, retry: u32) -> Duration {
358    backoff_with_full_jitter(base_ms, cap_ms, retry, jitter_rng())
359}
360
361/// Returns the sleep duration to use for a retry under an optional
362/// cross-attempt total deadline (REL-01), or `None` when the deadline has
363/// been exhausted and the retry must be aborted. When the deadline is set
364/// and the full `delay` would cross it, the sleep is clamped to the
365/// remaining slice so the loop never sleeps past the deadline. Pure in
366/// `elapsed` so tests can pin the clock; the production wrapper is
367/// [`retry_sleep_within_total`].
368///
369/// - `total_deadline == None`: the full `delay` (unbounded, the v1 default).
370/// - `elapsed >= total_deadline`: `None` (abort the retry).
371/// - otherwise: `min(delay, total_deadline - elapsed)`.
372pub fn retry_sleep_under_total_deadline(
373    total_deadline: Option<Duration>,
374    elapsed: Duration,
375    delay: Duration,
376) -> Option<Duration> {
377    match total_deadline {
378        None => Some(delay),
379        Some(deadline) => {
380            if elapsed >= deadline {
381                None
382            } else {
383                Some(delay.min(deadline - elapsed))
384            }
385        }
386    }
387}
388
389/// [`retry_sleep_under_total_deadline`] reading the wall clock from
390/// `started` (the instant captured at the first attempt of the retry loop).
391pub fn retry_sleep_within_total(
392    total_deadline: Option<Duration>,
393    started: std::time::Instant,
394    delay: Duration,
395) -> Option<Duration> {
396    retry_sleep_under_total_deadline(total_deadline, started.elapsed(), delay)
397}