Skip to main content

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}