Skip to main content

chainrpc_core/policy/
retry.rs

1//! Exponential backoff retry policy with optional jitter.
2
3use std::time::Duration;
4
5/// Configuration for the retry policy.
6#[derive(Debug, Clone)]
7pub struct RetryConfig {
8    /// Maximum number of retry attempts (not counting the first try).
9    pub max_retries: u32,
10    /// Initial backoff delay.
11    pub initial_backoff: Duration,
12    /// Maximum backoff delay (caps exponential growth).
13    pub max_backoff: Duration,
14    /// Multiplier applied to backoff on each retry.
15    pub multiplier: f64,
16    /// Add ±`jitter_fraction * backoff` random jitter (0.0 = no jitter).
17    pub jitter_fraction: f64,
18}
19
20impl Default for RetryConfig {
21    fn default() -> Self {
22        Self {
23            max_retries: 3,
24            initial_backoff: Duration::from_millis(100),
25            max_backoff: Duration::from_secs(10),
26            multiplier: 2.0,
27            jitter_fraction: 0.1,
28        }
29    }
30}
31
32/// Stateless retry policy — computes the next delay given the attempt number.
33#[derive(Debug, Clone)]
34pub struct RetryPolicy {
35    pub config: RetryConfig,
36}
37
38impl RetryPolicy {
39    pub fn new(config: RetryConfig) -> Self {
40        Self { config }
41    }
42
43    /// Returns the delay before the `attempt`-th retry (1-based).
44    /// Returns `None` if `attempt` exceeds `max_retries`.
45    pub fn next_delay(&self, attempt: u32) -> Option<Duration> {
46        if attempt > self.config.max_retries {
47            return None;
48        }
49        let base_ms = self.config.initial_backoff.as_millis() as f64
50            * self.config.multiplier.powi((attempt - 1) as i32);
51        let cap_ms = self.config.max_backoff.as_millis() as f64;
52        let capped = base_ms.min(cap_ms);
53
54        // Time-based jitter: use current time nanos as entropy source
55        let jitter_ms = if self.config.jitter_fraction > 0.0 {
56            let nanos = std::time::SystemTime::now()
57                .duration_since(std::time::UNIX_EPOCH)
58                .unwrap_or_default()
59                .subsec_nanos();
60            // Map nanos to [-1.0, 1.0] range
61            let random_factor = (nanos as f64 / u32::MAX as f64) * 2.0 - 1.0;
62            capped * self.config.jitter_fraction * random_factor
63        } else {
64            0.0
65        };
66
67        let total_ms = ((capped + jitter_ms).max(1.0)) as u64;
68        Some(Duration::from_millis(total_ms))
69    }
70
71    /// Returns `true` if any retries remain after `attempt` failures.
72    pub fn should_retry(&self, attempt: u32) -> bool {
73        attempt <= self.config.max_retries
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn first_retry_delay() {
83        let policy = RetryPolicy::new(RetryConfig {
84            max_retries: 3,
85            initial_backoff: Duration::from_millis(100),
86            max_backoff: Duration::from_secs(30),
87            multiplier: 2.0,
88            jitter_fraction: 0.0,
89        });
90        let d1 = policy.next_delay(1).unwrap();
91        let d2 = policy.next_delay(2).unwrap();
92        let d3 = policy.next_delay(3).unwrap();
93        assert_eq!(d1.as_millis(), 100);
94        assert_eq!(d2.as_millis(), 200);
95        assert_eq!(d3.as_millis(), 400);
96        assert!(policy.next_delay(4).is_none());
97    }
98
99    #[test]
100    fn delay_capped_at_max() {
101        let policy = RetryPolicy::new(RetryConfig {
102            max_retries: 10,
103            initial_backoff: Duration::from_millis(100),
104            max_backoff: Duration::from_millis(500),
105            multiplier: 10.0,
106            jitter_fraction: 0.0,
107        });
108        // After a few doublings it should be capped
109        let d5 = policy.next_delay(5).unwrap();
110        assert!(d5 <= Duration::from_millis(500), "d5={d5:?} exceeds max");
111    }
112
113    #[test]
114    fn should_retry_boundary() {
115        let policy = RetryPolicy::new(RetryConfig {
116            max_retries: 2,
117            ..Default::default()
118        });
119        assert!(policy.should_retry(1));
120        assert!(policy.should_retry(2));
121        assert!(!policy.should_retry(3));
122    }
123}