Skip to main content

qubit_retry/options/
retry_delay.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026.
4 *    Haixing Hu, Qubit Co. Ltd.
5 *
6 *    All rights reserved.
7 *
8 ******************************************************************************/
9//! RetryDelay strategies for retry attempts.
10//!
11//! A [`RetryDelay`] produces the base sleep duration after a failed attempt. The
12//! base duration is calculated before [`crate::RetryJitter`] is applied by a retry
13//! executor.
14//!
15//! # Text interchange
16//!
17//! [`std::fmt::Display`] and [`std::str::FromStr`] share a canonical string form:
18//!
19//! - `none`
20//! - `fixed(<duration>)` — duration fields are displayed as saturated whole milliseconds
21//!   with an `ms` suffix; `FromStr` accepts any duration string parsed by
22//!   [`qubit_common::serde::duration_with_unit`]
23//! - `random(<min>..=<max>)` — same rules for the two duration fields
24//! - `exponential(initial=<...>, max=<...>, multiplier=<f64>)` — same for `initial` and `max`
25//!
26//! For [`std::str::FromStr`], substrings for duration fields follow
27//! [`qubit_common::serde::duration_with_unit`] (bare integer as milliseconds, unit
28//! suffixes, etc.; see that module). [`std::fmt::Display`] normalizes to whole
29//! millisecond + `ms` for those fields.
30
31use std::str::FromStr;
32use std::time::Duration;
33
34use parse_display::{Display, FromStr};
35use rand::RngExt;
36use serde::{Deserialize, Serialize};
37
38use super::retry_delay_duration_format::RetryDelayDurationFormat;
39use crate::constants::DEFAULT_RETRY_DELAY;
40
41/// Base delay strategy before jitter is applied.
42///
43/// RetryDelay strategies are value types that can be reused across executors. Random
44/// and exponential strategies are validated separately by [`RetryDelay::validate`],
45/// which is called when building [`crate::RetryOptions`].
46#[derive(Debug, Clone, PartialEq, Display, FromStr, Serialize, Deserialize)]
47pub enum RetryDelay {
48    /// Retry immediately.
49    #[display("none")]
50    None,
51
52    /// Wait for a constant delay after every failed attempt.
53    #[display("fixed({0})")]
54    Fixed(
55        #[display(with = RetryDelayDurationFormat)]
56        #[serde(with = "qubit_common::serde::duration_millis")]
57        Duration,
58    ),
59
60    /// Pick a delay uniformly from the inclusive range.
61    #[display("random({min}..={max})")]
62    Random {
63        /// Lower bound for the delay.
64        #[display(with = RetryDelayDurationFormat)]
65        #[serde(with = "qubit_common::serde::duration_millis")]
66        min: Duration,
67        /// Upper bound for the delay.
68        #[display(with = RetryDelayDurationFormat)]
69        #[serde(with = "qubit_common::serde::duration_millis")]
70        max: Duration,
71    },
72
73    /// Exponential backoff capped by `max`.
74    #[display("exponential(initial={initial}, max={max}, multiplier={multiplier})")]
75    Exponential {
76        /// RetryDelay used for the first retry.
77        #[display(with = RetryDelayDurationFormat)]
78        #[serde(with = "qubit_common::serde::duration_millis")]
79        initial: Duration,
80        /// Maximum delay.
81        #[display(with = RetryDelayDurationFormat)]
82        #[serde(with = "qubit_common::serde::duration_millis")]
83        max: Duration,
84        /// Multiplicative factor applied per failed attempt.
85        multiplier: f64,
86    },
87}
88
89impl RetryDelay {
90    /// Creates a no-delay strategy.
91    ///
92    /// # Parameters
93    /// This function has no parameters.
94    ///
95    /// # Returns
96    /// A [`RetryDelay::None`] strategy.
97    ///
98    /// # Errors
99    /// This function does not return errors.
100    #[inline]
101    pub fn none() -> Self {
102        Self::None
103    }
104
105    /// Creates a fixed-delay strategy.
106    ///
107    /// # Parameters
108    /// - `delay`: Duration slept after each failed attempt.
109    ///
110    /// # Returns
111    /// A [`RetryDelay::Fixed`] strategy.
112    ///
113    /// # Errors
114    /// This constructor does not validate `delay`; use [`RetryDelay::validate`] to
115    /// reject a zero duration.
116    #[inline]
117    pub fn fixed(delay: Duration) -> Self {
118        Self::Fixed(delay)
119    }
120
121    /// Creates a random-delay strategy.
122    ///
123    /// # Parameters
124    /// - `min`: Inclusive lower bound for generated delays.
125    /// - `max`: Inclusive upper bound for generated delays.
126    ///
127    /// # Returns
128    /// A [`RetryDelay::Random`] strategy.
129    ///
130    /// # Errors
131    /// This constructor does not validate the range; use [`RetryDelay::validate`] to
132    /// reject a zero minimum or a minimum greater than the maximum.
133    #[inline]
134    pub fn random(min: Duration, max: Duration) -> Self {
135        Self::Random { min, max }
136    }
137
138    /// Creates an exponential-backoff strategy.
139    ///
140    /// # Parameters
141    /// - `initial`: RetryDelay used for the first retry.
142    /// - `max`: Upper bound applied to every calculated delay.
143    /// - `multiplier`: Factor applied for each subsequent failed attempt.
144    ///
145    /// # Returns
146    /// A [`RetryDelay::Exponential`] strategy.
147    ///
148    /// # Errors
149    /// This constructor does not validate the parameters; use
150    /// [`RetryDelay::validate`] to reject a zero initial delay, `max < initial`, or
151    /// a multiplier that is non-finite or less than or equal to `1.0`.
152    #[inline]
153    pub fn exponential(initial: Duration, max: Duration, multiplier: f64) -> Self {
154        Self::Exponential {
155            initial,
156            max,
157            multiplier,
158        }
159    }
160
161    /// Calculates the base delay for an attempt number starting at 1.
162    ///
163    /// Attempt `1` means the first failed attempt, so exponential backoff
164    /// returns `initial` for attempts `0` and `1`. Random delays use a fresh
165    /// random value for every call.
166    ///
167    /// # Parameters
168    /// - `attempt`: Failed attempt number. Values `0` and `1` are treated as
169    ///   the first exponential-backoff step.
170    ///
171    /// # Returns
172    /// The base delay before jitter is applied.
173    ///
174    /// # Errors
175    /// This function does not return errors. Invalid strategies should be
176    /// rejected with [`RetryDelay::validate`] before they are used in an executor.
177    pub fn base_delay(&self, attempt: u32) -> Duration {
178        match self {
179            Self::None => Duration::ZERO,
180            Self::Fixed(delay) => *delay,
181            Self::Random { min, max } => {
182                if min >= max {
183                    return *min;
184                }
185                let mut rng = rand::rng();
186                let min_nanos = Self::duration_to_nanos_u64(*min);
187                let max_nanos = Self::duration_to_nanos_u64(*max);
188                Duration::from_nanos(rng.random_range(min_nanos..=max_nanos))
189            }
190            Self::Exponential {
191                initial,
192                max,
193                multiplier,
194            } => Self::exponential_delay(*initial, *max, *multiplier, attempt),
195        }
196    }
197
198    /// Converts a [`Duration`] to whole nanoseconds as `u64`.
199    ///
200    /// Values larger than [`u64::MAX`] nanoseconds are saturated to
201    /// [`u64::MAX`] so the result fits in `u64` for uniform random delay sampling
202    /// in [`RetryDelay::base_delay`].
203    ///
204    /// # Parameters
205    /// - `duration`: Duration to convert.
206    ///
207    /// # Returns
208    /// The duration in nanoseconds, capped at [`u64::MAX`].
209    ///
210    /// # Errors
211    /// This function does not return errors.
212    fn duration_to_nanos_u64(duration: Duration) -> u64 {
213        duration.as_nanos().min(u64::MAX as u128) as u64
214    }
215
216    /// Computes the exponential backoff delay for a given failed-attempt index.
217    ///
218    /// The effective exponent is `attempt.saturating_sub(1)`, so attempts `0`
219    /// and `1` both yield the initial delay (matching [`RetryDelay::base_delay`]).
220    /// Each further attempt multiplies the base nanosecond count by
221    /// `multiplier` that many times, then the result is capped at `max`.
222    ///
223    /// # Parameters
224    /// - `initial`: RetryDelay for the first retry step (attempts `0` and `1`).
225    /// - `max`: Upper bound on the returned delay.
226    /// - `multiplier`: Factor applied per additional attempt beyond the first.
227    /// - `attempt`: Failed attempt number (see [`RetryDelay::base_delay`]).
228    ///
229    /// # Returns
230    /// The computed delay, or `max` when the scaled value is not finite or is
231    /// not less than `max` in nanoseconds.
232    ///
233    /// # Errors
234    /// This function does not return errors. Callers must ensure parameters
235    /// satisfy [`RetryDelay::validate`] when constructing a public executor.
236    fn exponential_delay(
237        initial: Duration,
238        max: Duration,
239        multiplier: f64,
240        attempt: u32,
241    ) -> Duration {
242        let power = attempt.saturating_sub(1);
243        let factor = multiplier.powi(power.min(i32::MAX as u32) as i32);
244        if !factor.is_finite() {
245            return max;
246        }
247        let secs = initial.as_secs_f64() * factor;
248        if !secs.is_finite() || secs >= max.as_secs_f64() {
249            return max;
250        }
251        Duration::try_from_secs_f64(secs).map_or(max, |delay| delay.min(max))
252    }
253
254    /// Validates strategy parameters.
255    ///
256    /// Returns a human-readable message describing the invalid field when the
257    /// strategy cannot be used safely by an executor.
258    ///
259    /// # Returns
260    /// `Ok(())` when all parameters are usable; otherwise an error message that
261    /// can be wrapped by [`crate::RetryConfigError`].
262    ///
263    /// # Parameters
264    /// This method has no parameters.
265    ///
266    /// # Errors
267    /// Returns an error when a fixed delay is zero, a random range is invalid,
268    /// or exponential backoff parameters are zero, inverted, non-finite, or too
269    /// small.
270    pub fn validate(&self) -> Result<(), String> {
271        match self {
272            Self::None => Ok(()),
273            Self::Fixed(delay) => {
274                if delay.is_zero() {
275                    Err("fixed delay cannot be zero".to_string())
276                } else {
277                    Ok(())
278                }
279            }
280            Self::Random { min, max } => {
281                if min.is_zero() {
282                    Err("random delay minimum cannot be zero".to_string())
283                } else if min > max {
284                    Err("random delay minimum cannot be greater than maximum".to_string())
285                } else {
286                    Ok(())
287                }
288            }
289            Self::Exponential {
290                initial,
291                max,
292                multiplier,
293            } => {
294                if initial.is_zero() {
295                    Err("exponential delay initial value cannot be zero".to_string())
296                } else if max < initial {
297                    Err("exponential delay maximum cannot be smaller than initial".to_string())
298                } else if !multiplier.is_finite() || *multiplier <= 1.0 {
299                    Err(
300                        "exponential delay multiplier must be finite and greater than 1.0"
301                            .to_string(),
302                    )
303                } else {
304                    Ok(())
305                }
306            }
307        }
308    }
309}
310
311impl Default for RetryDelay {
312    /// Creates the default exponential-backoff strategy.
313    ///
314    /// # Returns
315    /// The value obtained by parsing [`crate::constants::DEFAULT_RETRY_DELAY`]
316    /// using [`RetryDelay::from_str`].
317    ///
318    /// # Parameters
319    /// This function has no parameters.
320    ///
321    /// # Errors
322    /// This function does not return errors.
323    ///
324    /// # Panics
325    /// Panics if [`crate::constants::DEFAULT_RETRY_DELAY`] is not a valid
326    /// [`RetryDelay`] string. That indicates a crate bug, not a caller mistake.
327    #[inline]
328    fn default() -> Self {
329        Self::from_str(DEFAULT_RETRY_DELAY)
330            .expect("DEFAULT_RETRY_DELAY must be a valid RetryDelay string")
331    }
332}