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}