Skip to main content

restate_sdk_shared_core/
retries.rs

1use crate::EntryRetryInfo;
2use std::cmp;
3use std::time::Duration;
4
5/// What to do when a `RetryPolicy` runs out of attempts or duration.
6#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
7pub enum OnMaxAttempts {
8    /// Convert the retryable failure into a terminal failure on the run handle.
9    #[default]
10    FailAsTerminal,
11    /// Pause the invocation instead of failing it. The invocation MUST be manually resumed by the user.
12    /// Requires service protocol V7 or newer.
13    Pause,
14}
15
16/// This struct represents the policy to execute retries.
17#[derive(Debug, Clone, Default)]
18pub enum RetryPolicy {
19    /// # Infinite
20    ///
21    /// Infinite retry strategy.
22    #[default]
23    Infinite,
24    /// # None
25    ///
26    /// No retry strategy, fail on first failure.
27    None,
28    /// # Fixed delay
29    ///
30    /// Retry with a fixed delay strategy.
31    FixedDelay {
32        /// # Interval
33        ///
34        /// Interval between retries. If none, the runtime will provide one based on the invoker retry policy.
35        interval: Option<Duration>,
36
37        /// # Max attempts
38        ///
39        /// Gives up retrying when either this number of attempts is reached,
40        /// or `max_duration` (if set) is reached first.
41        /// Infinite retries if this field and `max_duration` are unset.
42        max_attempts: Option<u32>,
43
44        /// # Max duration
45        ///
46        /// Gives up retrying when either the retry loop lasted for this given max duration,
47        /// or `max_attempts` (if set) is reached first.
48        /// Infinite retries if this field and `max_attempts` are unset.
49        max_duration: Option<Duration>,
50
51        /// # On max attempts
52        ///
53        /// What to do once `max_attempts` or `max_duration` is reached.
54        on_max_attempts: OnMaxAttempts,
55    },
56    /// # Exponential
57    ///
58    /// Retry with an exponential strategy. The next retry is computed as `min(last_retry_interval * factor, max_interval)`.
59    Exponential {
60        /// # Initial Interval
61        ///
62        /// Initial interval for the first retry attempt.
63        initial_interval: Duration,
64
65        /// # Factor
66        ///
67        /// The factor to use to compute the next retry attempt. This value should be higher than 1.0
68        factor: f32,
69
70        /// # Max interval
71        ///
72        /// Maximum interval between retries.
73        max_interval: Option<Duration>,
74
75        /// # Max attempts
76        ///
77        /// Gives up retrying when either this number of attempts is reached,
78        /// or `max_duration` (if set) is reached first.
79        /// Infinite retries if this field and `max_duration` are unset.
80        max_attempts: Option<u32>,
81
82        /// # Max duration
83        ///
84        /// Gives up retrying when either the retry loop lasted for this given max duration,
85        /// or `max_attempts` (if set) is reached first.
86        /// Infinite retries if this field and `max_attempts` are unset.
87        max_duration: Option<Duration>,
88
89        /// # On max attempts
90        ///
91        /// What to do once `max_attempts` or `max_duration` is reached.
92        on_max_attempts: OnMaxAttempts,
93    },
94}
95
96#[derive(Debug, Clone, Eq, PartialEq)]
97pub(crate) enum NextRetry {
98    Retry(Option<Duration>),
99    FailAsTerminal,
100    Pause,
101}
102
103impl RetryPolicy {
104    pub fn fixed_delay(
105        interval: Option<Duration>,
106        max_attempts: Option<u32>,
107        max_duration: Option<Duration>,
108        on_max_attempts: OnMaxAttempts,
109    ) -> Self {
110        Self::FixedDelay {
111            interval,
112            max_attempts,
113            max_duration,
114            on_max_attempts,
115        }
116    }
117
118    pub fn exponential(
119        initial_interval: Duration,
120        factor: f32,
121        max_attempts: Option<u32>,
122        max_interval: Option<Duration>,
123        max_duration: Option<Duration>,
124        on_max_attempts: OnMaxAttempts,
125    ) -> Self {
126        Self::Exponential {
127            initial_interval,
128            factor,
129            max_attempts,
130            max_interval,
131            max_duration,
132            on_max_attempts,
133        }
134    }
135
136    pub(crate) fn should_pause_on_max_attempts(&self) -> bool {
137        matches!(
138            self,
139            RetryPolicy::FixedDelay {
140                on_max_attempts: OnMaxAttempts::Pause,
141                ..
142            } | RetryPolicy::Exponential {
143                on_max_attempts: OnMaxAttempts::Pause,
144                ..
145            }
146        )
147    }
148
149    pub(crate) fn next_retry(&self, retry_info: EntryRetryInfo) -> NextRetry {
150        match self {
151            RetryPolicy::Infinite => NextRetry::Retry(None),
152            RetryPolicy::None => NextRetry::FailAsTerminal,
153            RetryPolicy::FixedDelay {
154                interval,
155                max_attempts,
156                max_duration,
157                on_max_attempts,
158            } => {
159                if max_attempts.is_some_and(|max_attempts| max_attempts <= retry_info.retry_count)
160                    || max_duration
161                        .is_some_and(|max_duration| max_duration <= retry_info.retry_loop_duration)
162                {
163                    // Reached either max_attempts or max_duration bound
164                    return match on_max_attempts {
165                        OnMaxAttempts::FailAsTerminal => NextRetry::FailAsTerminal,
166                        OnMaxAttempts::Pause => NextRetry::Pause,
167                    };
168                }
169
170                // No bound reached, we need to retry
171                NextRetry::Retry(*interval)
172            }
173            RetryPolicy::Exponential {
174                initial_interval,
175                factor,
176                max_interval,
177                max_attempts,
178                max_duration,
179                on_max_attempts,
180            } => {
181                if max_attempts.is_some_and(|max_attempts| max_attempts <= retry_info.retry_count)
182                    || max_duration
183                        .is_some_and(|max_duration| max_duration <= retry_info.retry_loop_duration)
184                {
185                    // Reached either max_attempts or max_duration bound
186                    return match on_max_attempts {
187                        OnMaxAttempts::FailAsTerminal => NextRetry::FailAsTerminal,
188                        OnMaxAttempts::Pause => NextRetry::Pause,
189                    };
190                }
191
192                let max_interval = max_interval.unwrap_or(Duration::MAX);
193
194                // Next interval in the backoff sequence:
195                // initial_interval * factor^(retry_count - 1)
196                // Uses saturating and try to avoid overflows.
197                let exponent =
198                    i32::try_from(retry_info.retry_count.saturating_sub(1)).unwrap_or(i32::MAX);
199                let Ok(next_interval) = Duration::try_from_secs_f32(
200                    initial_interval.as_secs_f32() * factor.powi(exponent),
201                ) else {
202                    // Overflow, return max_interval instead.
203                    return NextRetry::Retry(Some(max_interval));
204                };
205
206                NextRetry::Retry(Some(cmp::min(max_interval, next_interval)))
207            }
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use googletest::prelude::*;
216    use rstest::rstest;
217
218    // No max_attempts / max_duration / max_interval => always growing retries.
219    #[test]
220    fn exponential_policy_does_not_panic_on_overflow() {
221        let policy = RetryPolicy::Exponential {
222            initial_interval: Duration::from_secs(1),
223            factor: 2.0,
224            max_interval: None,
225            max_attempts: None,
226            max_duration: None,
227            on_max_attempts: OnMaxAttempts::FailAsTerminal,
228        };
229
230        // Iterate well past the overflow boundary (~retry_count 65): every retry must
231        // stay within Duration::MAX and never panic.
232        for retry_count in 1..=200 {
233            assert_that!(
234                policy.next_retry(EntryRetryInfo {
235                    retry_count,
236                    retry_loop_duration: Duration::ZERO,
237                }),
238                pat!(NextRetry::Retry(some(le(Duration::MAX)))),
239                "retry_count={retry_count}"
240            );
241        }
242    }
243
244    #[rstest]
245    // factor^0 == 1: the first retry uses the initial interval.
246    #[case::first_retry_uses_initial(Duration::from_secs(1), 2.0, None, 1, Duration::from_secs(1))]
247    #[case::in_range_grows_by_factor(Duration::from_secs(1), 2.0, None, 3, Duration::from_secs(4))]
248    // Unbounded saturates to Duration::MAX rather than panicking, at and past the boundary.
249    #[case::overflow_boundary_unbounded(Duration::from_secs(1), 2.0, None, 70, Duration::MAX)]
250    #[case::large_retry_count_unbounded(Duration::from_secs(1), 2.0, None, 128, Duration::MAX)]
251    #[case::max_retry_count_unbounded(Duration::from_secs(1), 2.0, None, u32::MAX, Duration::MAX)]
252    // Bounded saturates to max_interval, at and past the boundary.
253    #[case::overflow_boundary_bounded(
254        Duration::from_secs(1),
255        2.0,
256        Some(Duration::from_secs(30)),
257        70,
258        Duration::from_secs(30)
259    )]
260    #[case::large_retry_count_bounded(
261        Duration::from_secs(1),
262        2.0,
263        Some(Duration::from_secs(30)),
264        128,
265        Duration::from_secs(30)
266    )]
267    #[case::max_retry_count_bounded(
268        Duration::from_secs(1),
269        2.0,
270        Some(Duration::from_secs(30)),
271        u32::MAX,
272        Duration::from_secs(30)
273    )]
274    // factor == 1 never grows.
275    #[case::factor_one_never_grows(Duration::from_secs(2), 1.0, None, 1000, Duration::from_secs(2))]
276    // Extreme / non-finite factors saturate to the ceiling.
277    #[case::huge_factor_bounded(
278        Duration::from_secs(1),
279        1e30,
280        Some(Duration::from_secs(30)),
281        5,
282        Duration::from_secs(30)
283    )]
284    #[case::huge_factor_unbounded(Duration::from_secs(1), 1e30, None, 5, Duration::MAX)]
285    #[case::nan_factor_bounded(
286        Duration::from_secs(1),
287        f32::NAN,
288        Some(Duration::from_secs(30)),
289        5,
290        Duration::from_secs(30)
291    )]
292    #[case::infinite_factor_unbounded(
293        Duration::from_secs(1),
294        f32::INFINITY,
295        None,
296        5,
297        Duration::MAX
298    )]
299    fn exponential_policy_saturation(
300        #[case] initial_interval: Duration,
301        #[case] factor: f32,
302        #[case] max_interval: Option<Duration>,
303        #[case] retry_count: u32,
304        #[case] expected: Duration,
305    ) {
306        let policy = RetryPolicy::Exponential {
307            initial_interval,
308            factor,
309            max_interval,
310            max_attempts: None,
311            max_duration: None,
312            on_max_attempts: OnMaxAttempts::FailAsTerminal,
313        };
314
315        assert_eq!(
316            policy.next_retry(EntryRetryInfo {
317                retry_count,
318                retry_loop_duration: Duration::ZERO,
319            }),
320            NextRetry::Retry(Some(expected))
321        );
322    }
323
324    #[test]
325    fn test_exponential_policy() {
326        // Intervals are computed in f32, so use f32-exact powers of two
327        // (125ms * 2^n) to compare exactly rather than depending on rounding.
328        let policy = RetryPolicy::Exponential {
329            initial_interval: Duration::from_millis(125),
330            factor: 2.0,
331            max_interval: Some(Duration::from_millis(750)),
332            max_attempts: None,
333            max_duration: Some(Duration::from_secs(10)),
334            on_max_attempts: OnMaxAttempts::FailAsTerminal,
335        };
336
337        // 125ms * 2^1
338        assert_eq!(
339            policy.next_retry(EntryRetryInfo {
340                retry_count: 2,
341                retry_loop_duration: Duration::from_secs(1)
342            }),
343            NextRetry::Retry(Some(Duration::from_millis(250)))
344        );
345        // 125ms * 2^2, still below max_interval
346        assert_eq!(
347            policy.next_retry(EntryRetryInfo {
348                retry_count: 3,
349                retry_loop_duration: Duration::from_secs(1)
350            }),
351            NextRetry::Retry(Some(Duration::from_millis(500)))
352        );
353        // 125ms * 2^3 == 1s, clamped to max_interval
354        assert_eq!(
355            policy.next_retry(EntryRetryInfo {
356                retry_count: 4,
357                retry_loop_duration: Duration::from_secs(1)
358            }),
359            NextRetry::Retry(Some(Duration::from_millis(750)))
360        );
361        assert_eq!(
362            policy.next_retry(EntryRetryInfo {
363                retry_count: 4,
364                retry_loop_duration: Duration::from_secs(10)
365            }),
366            NextRetry::FailAsTerminal
367        );
368    }
369}