Skip to main content

orleans_rust_client/
retry.rs

1//! Conservative, opt-in retry policy.
2//!
3//! Retries are **disabled by default**. Grain calls are not assumed to be
4//! idempotent, so automatic retries can only be safe when the bridge reports
5//! an error as `retryable` (for example a placement rejection that never
6//! reached the grain). Enable retries explicitly via
7//! [`crate::OrleansClientBuilder::retry_policy`].
8
9use std::time::Duration;
10
11/// Exponential-backoff retry configuration.
12#[derive(Debug, Clone)]
13pub struct RetryPolicy {
14    /// Maximum number of retries after the initial attempt. Zero disables
15    /// retries.
16    pub max_retries: u32,
17    /// Backoff before the first retry.
18    pub initial_backoff: Duration,
19    /// Upper bound on backoff between retries.
20    pub max_backoff: Duration,
21    /// Multiplier applied to the backoff after each attempt.
22    pub backoff_multiplier: f64,
23}
24
25impl RetryPolicy {
26    /// No retries. This is the default.
27    #[must_use]
28    pub fn disabled() -> Self {
29        Self {
30            max_retries: 0,
31            initial_backoff: Duration::ZERO,
32            max_backoff: Duration::ZERO,
33            backoff_multiplier: 1.0,
34        }
35    }
36
37    /// A deliberately small policy: at most two retries with capped backoff,
38    /// suitable only for errors the bridge has flagged as retryable.
39    #[must_use]
40    pub fn conservative() -> Self {
41        Self {
42            max_retries: 2,
43            initial_backoff: Duration::from_millis(100),
44            max_backoff: Duration::from_secs(2),
45            backoff_multiplier: 2.0,
46        }
47    }
48
49    /// Whether any retries are permitted.
50    #[must_use]
51    pub fn is_enabled(&self) -> bool {
52        self.max_retries > 0
53    }
54
55    /// Backoff before the retry numbered `attempt` (1-based).
56    #[must_use]
57    pub fn backoff_for(&self, attempt: u32) -> Duration {
58        if attempt == 0 {
59            return Duration::ZERO;
60        }
61        let exp = self.backoff_multiplier.powi((attempt - 1) as i32);
62        let millis = self.initial_backoff.as_secs_f64() * 1000.0 * exp;
63        let capped = millis.min(self.max_backoff.as_secs_f64() * 1000.0);
64        Duration::from_millis(capped as u64)
65    }
66}
67
68impl Default for RetryPolicy {
69    fn default() -> Self {
70        Self::disabled()
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn default_is_disabled() {
80        assert!(!RetryPolicy::default().is_enabled());
81        assert_eq!(RetryPolicy::default().backoff_for(1), Duration::ZERO);
82        // Attempt 0 has no backoff regardless of policy.
83        assert_eq!(RetryPolicy::conservative().backoff_for(0), Duration::ZERO);
84    }
85
86    #[test]
87    fn conservative_is_bounded() {
88        let policy = RetryPolicy::conservative();
89        assert!(policy.is_enabled());
90        assert_eq!(policy.max_retries, 2);
91    }
92
93    #[test]
94    fn backoff_grows_then_caps() {
95        let policy = RetryPolicy {
96            max_retries: 10,
97            initial_backoff: Duration::from_millis(100),
98            max_backoff: Duration::from_millis(500),
99            backoff_multiplier: 2.0,
100        };
101        assert_eq!(policy.backoff_for(1), Duration::from_millis(100));
102        assert_eq!(policy.backoff_for(2), Duration::from_millis(200));
103        assert_eq!(policy.backoff_for(3), Duration::from_millis(400));
104        // 800ms would exceed the 500ms cap.
105        assert_eq!(policy.backoff_for(4), Duration::from_millis(500));
106    }
107}