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}