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}