Skip to main content

qubit_retry/options/
retry_delay.rs

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