Skip to main content

mbus_core/transport/
retry.rs

1//! Retry scheduling: backoff strategies and jitter.
2
3/// Application-provided callback used to generate randomness for retry jitter.
4///
5/// The callback returns a raw `u32` value that is consumed by jitter logic.
6/// The distribution does not need to be cryptographically secure. A simple
7/// pseudo-random source from the target platform is sufficient.
8pub type RetryRandomFn = fn() -> u32;
9
10/// Retry delay strategy used after a request times out.
11///
12/// The delay is computed per retry attempt in a poll-driven manner. No internal
13/// sleeping or blocking waits are performed by the library.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum BackoffStrategy {
16    /// Retry immediately after timeout detection.
17    #[default]
18    Immediate,
19    /// Retry using a constant delay in milliseconds.
20    Fixed {
21        /// Delay applied before each retry.
22        delay_ms: u32,
23    },
24    /// Retry with an exponential sequence: `base_delay_ms * 2^(attempt-1)`.
25    Exponential {
26        /// Base delay for the first retry attempt.
27        base_delay_ms: u32,
28        /// Upper bound used to clamp growth.
29        max_delay_ms: u32,
30    },
31    /// Retry with a linear sequence: `initial_delay_ms + (attempt-1) * increment_ms`.
32    Linear {
33        /// Delay for the first retry attempt.
34        initial_delay_ms: u32,
35        /// Increment added on every subsequent retry.
36        increment_ms: u32,
37        /// Upper bound used to clamp growth.
38        max_delay_ms: u32,
39    },
40}
41
42impl BackoffStrategy {
43    /// Computes the base retry delay in milliseconds for a 1-based retry attempt index.
44    ///
45    /// `retry_attempt` is expected to start at `1` for the first retry after the
46    /// initial request timeout.
47    pub fn delay_ms_for_retry(&self, retry_attempt: u8) -> u32 {
48        let attempt = retry_attempt.max(1);
49        match self {
50            BackoffStrategy::Immediate => 0,
51            BackoffStrategy::Fixed { delay_ms } => *delay_ms,
52            BackoffStrategy::Exponential {
53                base_delay_ms,
54                max_delay_ms,
55            } => {
56                let shift = (attempt.saturating_sub(1)).min(31);
57                let factor = 1u32 << shift;
58                base_delay_ms.saturating_mul(factor).min(*max_delay_ms)
59            }
60            BackoffStrategy::Linear {
61                initial_delay_ms,
62                increment_ms,
63                max_delay_ms,
64            } => {
65                let growth = increment_ms.saturating_mul((attempt.saturating_sub(1)) as u32);
66                initial_delay_ms.saturating_add(growth).min(*max_delay_ms)
67            }
68        }
69    }
70}
71
72/// Jitter strategy applied on top of computed backoff delay.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum JitterStrategy {
75    /// Do not apply jitter.
76    #[default]
77    None,
78    /// Apply symmetric percentage jitter around the base delay.
79    ///
80    /// For example, with `percent = 20` and base `100ms`, the final delay is
81    /// in the range `[80ms, 120ms]`.
82    Percentage {
83        /// Maximum percentage variation from the base delay.
84        percent: u8,
85    },
86    /// Apply symmetric bounded jitter in milliseconds around the base delay.
87    ///
88    /// For example, with `max_jitter_ms = 15` and base `100ms`, the final delay is
89    /// in the range `[85ms, 115ms]`.
90    BoundedMs {
91        /// Maximum absolute jitter in milliseconds.
92        max_jitter_ms: u32,
93    },
94}
95
96impl JitterStrategy {
97    /// Applies jitter to `base_delay_ms` using an application-provided random callback.
98    ///
99    /// If jitter is disabled or no callback is provided, this method returns `base_delay_ms`.
100    pub fn apply(self, base_delay_ms: u32, random_fn: Option<RetryRandomFn>) -> u32 {
101        let delta = match self {
102            JitterStrategy::None => return base_delay_ms,
103            JitterStrategy::Percentage { percent } => {
104                if percent == 0 || base_delay_ms == 0 {
105                    return base_delay_ms;
106                }
107                base_delay_ms.saturating_mul((percent.min(100)) as u32) / 100
108            }
109            JitterStrategy::BoundedMs { max_jitter_ms } => {
110                if max_jitter_ms == 0 {
111                    return base_delay_ms;
112                }
113                max_jitter_ms
114            }
115        };
116
117        let random = match random_fn {
118            Some(cb) => cb(),
119            None => return base_delay_ms,
120        };
121
122        let span = delta.saturating_mul(2).saturating_add(1);
123        if span == 0 {
124            return base_delay_ms;
125        }
126
127        let offset = (random % span) as i64 - delta as i64;
128        let jittered = base_delay_ms as i64 + offset;
129        jittered.max(0) as u32
130    }
131}