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 ModelRetryPolicy {
68    /// Creates an exponential policy. `max_attempts` includes the first call.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ModelRetryPolicyError`] for a zero attempt count, zero
73    /// multiplier, or inverted delay range.
74    pub fn exponential(
75        max_attempts: u32,
76        initial_backoff: Duration,
77        max_backoff: Duration,
78        multiplier: u32,
79    ) -> Result<Self, ModelRetryPolicyError> {
80        if max_attempts == 0 {
81            return Err(ModelRetryPolicyError::ZeroMaxAttempts);
82        }
83        if multiplier == 0 {
84            return Err(ModelRetryPolicyError::ZeroMultiplier);
85        }
86        if max_backoff < initial_backoff {
87            return Err(ModelRetryPolicyError::InvalidBackoffRange);
88        }
89        Ok(Self {
90            max_attempts,
91            initial_backoff,
92            max_backoff,
93            multiplier,
94            jitter: RetryJitter::Full,
95            unknown_safety_kinds: Vec::new(),
96        })
97    }
98
99    /// Sets retry jitter.
100    #[must_use]
101    pub const fn jitter(mut self, jitter: RetryJitter) -> Self {
102        self.jitter = jitter;
103        self
104    }
105
106    /// Allows retry for one error kind whose retry safety is unknown.
107    ///
108    /// This is explicit authority to risk another provider charge. It never
109    /// overrides cancellation or an error marked unsafe.
110    #[must_use]
111    pub fn allow_unknown(mut self, kind: ModelErrorKind) -> Self {
112        if !self.unknown_safety_kinds.contains(&kind) {
113            self.unknown_safety_kinds.push(kind);
114        }
115        self
116    }
117
118    /// Returns the total attempt bound, including the initial attempt.
119    pub const fn max_attempts(&self) -> u32 {
120        self.max_attempts
121    }
122
123    /// Returns the initial exponential delay.
124    pub const fn initial_backoff(&self) -> Duration {
125        self.initial_backoff
126    }
127
128    /// Returns the delay cap.
129    pub const fn max_backoff(&self) -> Duration {
130        self.max_backoff
131    }
132
133    /// Returns the integer exponential multiplier.
134    pub const fn multiplier(&self) -> u32 {
135        self.multiplier
136    }
137
138    /// Returns the configured jitter mode.
139    pub const fn jitter_mode(&self) -> RetryJitter {
140        self.jitter
141    }
142
143    pub(crate) fn permits(&self, error: &ModelError) -> bool {
144        if error.kind == ModelErrorKind::Cancelled {
145            return false;
146        }
147        match error.retry_safety {
148            RetrySafety::Safe => true,
149            RetrySafety::Unknown => self.unknown_safety_kinds.contains(&error.kind),
150            _ => false,
151        }
152    }
153
154    pub(crate) fn delay(&self, retry: u32, entropy: u64) -> Duration {
155        let exponent = retry.saturating_sub(1);
156        let mut delay = self.initial_backoff;
157        for _ in 0..exponent {
158            if delay >= self.max_backoff {
159                break;
160            }
161            delay = delay
162                .checked_mul(self.multiplier)
163                .unwrap_or(self.max_backoff)
164                .min(self.max_backoff);
165        }
166        match self.jitter {
167            RetryJitter::None => delay,
168            RetryJitter::Full => full_jitter(delay, entropy),
169        }
170    }
171}
172
173fn full_jitter(cap: Duration, entropy: u64) -> Duration {
174    let cap_nanos = u64::try_from(cap.as_nanos()).unwrap_or(u64::MAX);
175    if cap_nanos == u64::MAX {
176        return Duration::from_nanos(entropy);
177    }
178    Duration::from_nanos(entropy % cap_nanos.saturating_add(1))
179}
180
181#[cfg(test)]
182mod tests {
183    use std::time::Duration;
184
185    use crate::{ModelError, ModelErrorKind};
186    use runifold_core::RetrySafety;
187
188    use super::{ModelRetryPolicy, ModelRetryPolicyError, RetryJitter};
189
190    #[test]
191    fn exponential_delay_is_capped_without_overflow() {
192        let policy = ModelRetryPolicy::exponential(
193            10,
194            Duration::from_millis(100),
195            Duration::from_secs(1),
196            3,
197        )
198        .unwrap()
199        .jitter(RetryJitter::None);
200
201        assert_eq!(policy.delay(1, 0), Duration::from_millis(100));
202        assert_eq!(policy.delay(2, 0), Duration::from_millis(300));
203        assert_eq!(policy.delay(3, 0), Duration::from_millis(900));
204        assert_eq!(policy.delay(4, 0), Duration::from_secs(1));
205        assert_eq!(policy.delay(u32::MAX, 0), Duration::from_secs(1));
206    }
207
208    #[test]
209    fn full_jitter_is_deterministic_and_within_cap() {
210        let policy = ModelRetryPolicy::exponential(
211            2,
212            Duration::from_millis(100),
213            Duration::from_millis(100),
214            2,
215        )
216        .unwrap();
217
218        let first = policy.delay(1, 42);
219        let second = policy.delay(1, 42);
220        assert_eq!(first, second);
221        assert!(first <= Duration::from_millis(100));
222    }
223
224    #[test]
225    fn invalid_policy_is_rejected() {
226        assert_eq!(
227            ModelRetryPolicy::exponential(
228                0,
229                Duration::from_millis(1),
230                Duration::from_millis(1),
231                2,
232            )
233            .unwrap_err(),
234            ModelRetryPolicyError::ZeroMaxAttempts
235        );
236    }
237
238    #[test]
239    fn unknown_error_requires_explicit_retry_authority() {
240        let policy = ModelRetryPolicy::exponential(2, Duration::ZERO, Duration::ZERO, 1).unwrap();
241        let mut error = ModelError::local(ModelErrorKind::Transport, "failure");
242        error.retry_safety = RetrySafety::Unknown;
243        assert!(!policy.permits(&error));
244        assert!(
245            policy
246                .allow_unknown(ModelErrorKind::Transport)
247                .permits(&error)
248        );
249    }
250}