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