Skip to main content

runifold_model/
retry.rs

1use std::{future::Future, pin::Pin, time::Duration};
2
3use runifold_core::RetrySafety;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{ModelError, ModelErrorKind};
8
9/// A boxed sleep future used by routing policy.
10pub type RouterSleepFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
11
12/// Asynchronous timer boundary used by retry backoff.
13pub trait RouterSleeper: Send + Sync {
14    /// Waits for the requested monotonic duration.
15    fn sleep(&self, duration: Duration) -> RouterSleepFuture<'_>;
16}
17
18/// Runtime-neutral production timer backed by `futures-timer`.
19#[derive(Clone, Copy, Debug, Default)]
20pub struct SystemRouterSleeper;
21
22impl RouterSleeper for SystemRouterSleeper {
23    fn sleep(&self, duration: Duration) -> RouterSleepFuture<'_> {
24        Box::pin(futures_timer::Delay::new(duration))
25    }
26}
27
28/// Jitter applied to an exponential retry delay.
29#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum RetryJitter {
33    /// Preserve the exact exponential delay.
34    None,
35    /// Select a deterministic per-invocation delay from zero through the
36    /// exponential cap.
37    #[default]
38    Full,
39}
40
41/// Invalid retry-policy configuration.
42#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
43#[non_exhaustive]
44pub enum ModelRetryPolicyError {
45    /// A policy must include the initial attempt.
46    #[error("model retry max_attempts must be greater than zero")]
47    ZeroMaxAttempts,
48    /// Exponential growth cannot use a zero multiplier.
49    #[error("model retry backoff multiplier must be greater than zero")]
50    ZeroMultiplier,
51    /// Maximum delay cannot be below the initial delay.
52    #[error("model retry max_backoff cannot be less than initial_backoff")]
53    InvalidBackoffRange,
54}
55
56/// Explicit same-route retry and backoff authority.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct ModelRetryPolicy {
59    max_attempts: u32,
60    initial_backoff: Duration,
61    max_backoff: Duration,
62    multiplier: u32,
63    jitter: RetryJitter,
64    unknown_safety_kinds: Vec<ModelErrorKind>,
65}
66
67impl Default for ModelRetryPolicy {
68    fn default() -> Self {
69        Self {
70            max_attempts: 3,
71            initial_backoff: Duration::from_millis(100),
72            max_backoff: Duration::from_secs(2),
73            multiplier: 2,
74            jitter: RetryJitter::Full,
75            unknown_safety_kinds: Vec::new(),
76        }
77    }
78}
79
80impl ModelRetryPolicy {
81    /// Creates an exponential policy. `max_attempts` includes the first call.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`ModelRetryPolicyError`] for a zero attempt count, zero
86    /// multiplier, or inverted delay range.
87    pub fn exponential(
88        max_attempts: u32,
89        initial_backoff: Duration,
90        max_backoff: Duration,
91        multiplier: u32,
92    ) -> Result<Self, ModelRetryPolicyError> {
93        if max_attempts == 0 {
94            return Err(ModelRetryPolicyError::ZeroMaxAttempts);
95        }
96        if multiplier == 0 {
97            return Err(ModelRetryPolicyError::ZeroMultiplier);
98        }
99        if max_backoff < initial_backoff {
100            return Err(ModelRetryPolicyError::InvalidBackoffRange);
101        }
102        Ok(Self {
103            max_attempts,
104            initial_backoff,
105            max_backoff,
106            multiplier,
107            jitter: RetryJitter::Full,
108            unknown_safety_kinds: Vec::new(),
109        })
110    }
111
112    /// Sets retry jitter.
113    #[must_use]
114    pub const fn jitter(mut self, jitter: RetryJitter) -> Self {
115        self.jitter = jitter;
116        self
117    }
118
119    /// Allows retry for one error kind whose retry safety is unknown.
120    ///
121    /// This is explicit authority to risk another provider charge. It never
122    /// overrides cancellation or an error marked unsafe.
123    #[must_use]
124    pub fn allow_unknown(mut self, kind: ModelErrorKind) -> Self {
125        if !self.unknown_safety_kinds.contains(&kind) {
126            self.unknown_safety_kinds.push(kind);
127        }
128        self
129    }
130
131    /// Returns the total attempt bound, including the initial attempt.
132    pub const fn max_attempts(&self) -> u32 {
133        self.max_attempts
134    }
135
136    /// Returns the initial exponential delay.
137    pub const fn initial_backoff(&self) -> Duration {
138        self.initial_backoff
139    }
140
141    /// Returns the delay cap.
142    pub const fn max_backoff(&self) -> Duration {
143        self.max_backoff
144    }
145
146    /// Returns the integer exponential multiplier.
147    pub const fn multiplier(&self) -> u32 {
148        self.multiplier
149    }
150
151    /// Returns the configured jitter mode.
152    pub const fn jitter_mode(&self) -> RetryJitter {
153        self.jitter
154    }
155
156    pub(crate) fn permits(&self, error: &ModelError) -> bool {
157        if error.kind == ModelErrorKind::Cancelled {
158            return false;
159        }
160        match error.retry_safety {
161            RetrySafety::Safe => true,
162            RetrySafety::Unknown => self.unknown_safety_kinds.contains(&error.kind),
163            _ => false,
164        }
165    }
166
167    pub(crate) fn delay(&self, retry: u32, entropy: u64) -> Duration {
168        let exponent = retry.saturating_sub(1);
169        let mut delay = self.initial_backoff;
170        for _ in 0..exponent {
171            if delay >= self.max_backoff {
172                break;
173            }
174            delay = delay
175                .checked_mul(self.multiplier)
176                .unwrap_or(self.max_backoff)
177                .min(self.max_backoff);
178        }
179        match self.jitter {
180            RetryJitter::None => delay,
181            RetryJitter::Full => full_jitter(delay, entropy),
182        }
183    }
184}
185
186fn full_jitter(cap: Duration, entropy: u64) -> Duration {
187    let cap_nanos = u64::try_from(cap.as_nanos()).unwrap_or(u64::MAX);
188    if cap_nanos == u64::MAX {
189        return Duration::from_nanos(entropy);
190    }
191    Duration::from_nanos(entropy % cap_nanos.saturating_add(1))
192}
193
194#[cfg(test)]
195mod tests {
196    use std::time::Duration;
197
198    use crate::{ModelError, ModelErrorKind};
199    use runifold_core::RetrySafety;
200
201    use super::{ModelRetryPolicy, ModelRetryPolicyError, RetryJitter};
202
203    #[test]
204    fn exponential_delay_is_capped_without_overflow() {
205        let policy = ModelRetryPolicy::exponential(
206            10,
207            Duration::from_millis(100),
208            Duration::from_secs(1),
209            3,
210        )
211        .unwrap()
212        .jitter(RetryJitter::None);
213
214        assert_eq!(policy.delay(1, 0), Duration::from_millis(100));
215        assert_eq!(policy.delay(2, 0), Duration::from_millis(300));
216        assert_eq!(policy.delay(3, 0), Duration::from_millis(900));
217        assert_eq!(policy.delay(4, 0), Duration::from_secs(1));
218        assert_eq!(policy.delay(u32::MAX, 0), Duration::from_secs(1));
219    }
220
221    #[test]
222    fn full_jitter_is_deterministic_and_within_cap() {
223        let policy = ModelRetryPolicy::exponential(
224            2,
225            Duration::from_millis(100),
226            Duration::from_millis(100),
227            2,
228        )
229        .unwrap();
230
231        let first = policy.delay(1, 42);
232        let second = policy.delay(1, 42);
233        assert_eq!(first, second);
234        assert!(first <= Duration::from_millis(100));
235    }
236
237    #[test]
238    fn invalid_policy_is_rejected() {
239        assert_eq!(
240            ModelRetryPolicy::exponential(
241                0,
242                Duration::from_millis(1),
243                Duration::from_millis(1),
244                2,
245            )
246            .unwrap_err(),
247            ModelRetryPolicyError::ZeroMaxAttempts
248        );
249    }
250
251    #[test]
252    fn default_policy_is_bounded_and_safe_only() {
253        let policy = ModelRetryPolicy::default();
254
255        assert_eq!(policy.max_attempts(), 3);
256        assert_eq!(policy.initial_backoff(), Duration::from_millis(100));
257        assert_eq!(policy.max_backoff(), Duration::from_secs(2));
258        assert_eq!(policy.multiplier(), 2);
259        assert_eq!(policy.jitter_mode(), RetryJitter::Full);
260    }
261
262    #[test]
263    fn unknown_error_requires_explicit_retry_authority() {
264        let policy = ModelRetryPolicy::exponential(2, Duration::ZERO, Duration::ZERO, 1).unwrap();
265        let mut error = ModelError::local(ModelErrorKind::Transport, "failure");
266        error.retry_safety = RetrySafety::Unknown;
267        assert!(!policy.permits(&error));
268        assert!(
269            policy
270                .allow_unknown(ModelErrorKind::Transport)
271                .permits(&error)
272        );
273    }
274}