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}