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