gglib_core/retry/policy.rs
1//! Pure backoff policy — no clock, no randomness source, no I/O.
2//!
3//! [`decide`] is a total function of its arguments: the caller owns the clock
4//! (passing `elapsed`) and the randomness (passing `jitter_unit`), so every
5//! backoff assertion in the test suite is exact and no test ever sleeps.
6//! Execution lives in the adapter layers that call this.
7
8use std::time::Duration;
9
10/// Bounds on a retry sequence.
11///
12/// Two independent limits apply, and whichever trips first wins:
13/// `max_attempts` caps how many times the work is tried, `total_deadline`
14/// caps the wall-clock time the whole sequence may consume. The deadline is
15/// what keeps a per-attempt timeout from multiplying — a 600 s send timeout
16/// retried four times must not become a 40-minute hang.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct RetryPolicy {
19 /// Total attempts, including the first. `1` disables retrying.
20 pub max_attempts: u32,
21 /// Backoff base for the first retry; doubles each subsequent retry.
22 pub initial_backoff: Duration,
23 /// Ceiling on any single delay, including a server-supplied `Retry-After`.
24 pub max_backoff: Duration,
25 /// Ceiling on the wall-clock time the whole sequence may consume.
26 pub total_deadline: Duration,
27}
28
29impl Default for RetryPolicy {
30 /// Defaults tuned for the LLM completion path.
31 ///
32 /// Deliberately modest: the proxy already absorbs `ModelLoading`
33 /// server-side, so a client-side retry is covering startup *contention*,
34 /// which by definition means something upstream has already waited a long
35 /// time. A larger budget here would stack on top of that and turn an
36 /// unlucky request into a multi-minute hang.
37 fn default() -> Self {
38 Self {
39 max_attempts: 4,
40 initial_backoff: Duration::from_secs(1),
41 max_backoff: Duration::from_secs(15),
42 total_deadline: Duration::from_mins(1),
43 }
44 }
45}
46
47/// Why a retry sequence stopped.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum GiveUpReason {
50 /// `max_attempts` reached.
51 AttemptsExhausted,
52 /// `total_deadline` reached, or the next delay would overrun it.
53 DeadlineExceeded,
54}
55
56impl GiveUpReason {
57 /// Short, stable description for logs and observer callbacks.
58 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::AttemptsExhausted => "attempts exhausted",
62 Self::DeadlineExceeded => "deadline exceeded",
63 }
64 }
65}
66
67/// The outcome of consulting the policy after a failed attempt.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum RetryDecision {
70 /// Sleep for `after`, then try again.
71 Retry {
72 /// How long to wait before the next attempt.
73 after: Duration,
74 },
75 /// Stop; the sequence has failed.
76 GiveUp(GiveUpReason),
77}
78
79/// Decide what to do after `attempt` attempts have failed.
80///
81/// `attempt` counts *completed* attempts, so it is `1` after the first failure.
82/// `elapsed` is the time consumed by the sequence so far. `jitter_unit` is a
83/// caller-supplied value in `[0.0, 1.0)`; it is clamped defensively so a bad
84/// caller cannot produce a negative or unbounded delay.
85///
86/// # Delay derivation
87///
88/// Without a server hint, this is **full jitter** — `random(0, min(cap,
89/// base·2ⁿ))`. The failure mode being defended against is several clients
90/// colliding on one model's startup, and full jitter is the variant that
91/// decorrelates them most aggressively. Fixed backoff would have every waiter
92/// wake together and collide again.
93///
94/// With a `Retry-After`, the server's value is treated as a **floor** rather
95/// than replaced by jitter: retrying earlier than the server asked just burns
96/// an attempt against a resource known to be unready. A small jitter of up to
97/// `initial_backoff` is added on top, so concurrent clients handed the same
98/// `Retry-After` still spread out. The floor is clamped to `max_backoff` first,
99/// so a buggy or hostile upstream cannot park a request indefinitely.
100///
101/// A delay that would overrun `total_deadline` yields
102/// [`GiveUpReason::DeadlineExceeded`] rather than a truncated sleep: waking
103/// early, before the moment the server nominated, is worse than stopping.
104#[must_use]
105pub fn decide(
106 policy: &RetryPolicy,
107 attempt: u32,
108 server_retry_after: Option<Duration>,
109 elapsed: Duration,
110 jitter_unit: f64,
111) -> RetryDecision {
112 if attempt >= policy.max_attempts {
113 return RetryDecision::GiveUp(GiveUpReason::AttemptsExhausted);
114 }
115 if elapsed >= policy.total_deadline {
116 return RetryDecision::GiveUp(GiveUpReason::DeadlineExceeded);
117 }
118
119 let delay = server_retry_after.map_or_else(
120 || full_jitter(policy, attempt, jitter_unit),
121 |hint| honour_server_hint(policy, hint, jitter_unit),
122 );
123
124 if elapsed.saturating_add(delay) >= policy.total_deadline {
125 return RetryDecision::GiveUp(GiveUpReason::DeadlineExceeded);
126 }
127
128 RetryDecision::Retry { after: delay }
129}
130
131/// Server hint as a floor, clamped to `max_backoff`, plus decorrelating jitter.
132fn honour_server_hint(policy: &RetryPolicy, hint: Duration, jitter_unit: f64) -> Duration {
133 let floor = hint.min(policy.max_backoff);
134 let spread = scale(policy.initial_backoff, jitter_unit);
135 floor.saturating_add(spread)
136}
137
138/// Full jitter over an exponentially growing, capped window.
139fn full_jitter(policy: &RetryPolicy, attempt: u32, jitter_unit: f64) -> Duration {
140 // `attempt` is 1-based, so the first retry uses `initial_backoff` unscaled.
141 // Shift width is capped well below `u64::BITS` so the doubling cannot
142 // overflow regardless of how large `max_attempts` is configured.
143 let exponent = attempt.saturating_sub(1).min(32);
144 let factor = 1u64 << exponent;
145
146 let base_ms = duration_ms(policy.initial_backoff).saturating_mul(factor);
147 let window_ms = base_ms.min(duration_ms(policy.max_backoff));
148
149 scale(Duration::from_millis(window_ms), jitter_unit)
150}
151
152/// Multiply a duration by a unit fraction, clamping the fraction to `[0.0, 1.0]`.
153fn scale(window: Duration, jitter_unit: f64) -> Duration {
154 let unit = if jitter_unit.is_finite() {
155 jitter_unit.clamp(0.0, 1.0)
156 } else {
157 0.0
158 };
159 // `as u64` on a non-negative, finite f64 bounded by `window_ms` cannot
160 // wrap; the product is at most the window itself.
161 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
162 #[allow(clippy::cast_sign_loss)]
163 let scaled_ms = (duration_ms(window) as f64 * unit) as u64;
164 Duration::from_millis(scaled_ms)
165}
166
167/// Saturating millisecond view of a duration.
168fn duration_ms(d: Duration) -> u64 {
169 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
170}