Skip to main content

qubit_retry/options/
retry_jitter.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//! Retry jitter applied on top of a base [`crate::RetryDelay`].
11//!
12//! After [`crate::RetryDelay`] yields a base sleep duration for the next attempt,
13//! [`RetryJitter`] optionally perturbs it so concurrent retries do not align on the
14//! same schedule.
15//!
16//! # Text interchange
17//!
18//! [`std::fmt::Display`] and [`std::str::FromStr`] use the same grammar:
19//!
20//! - `none` in any ASCII letter case (leading/trailing ASCII whitespace trimmed).
21//! - `factor:` followed by a floating-point literal in **`[0.0, 1.0]`**; optional
22//!   ASCII whitespace is allowed after the colon.
23//!
24//! The `factor:` prefix itself is **case-sensitive**. See
25//! [`crate::constants::DEFAULT_RETRY_JITTER`] for the library default string.
26//!
27
28use std::str::FromStr;
29use std::time::Duration;
30
31use parse_display::{
32    Display,
33    DisplayFormat,
34    FromStr as DeriveFromStr,
35    FromStrFormat,
36    ParseError,
37};
38use rand::RngExt;
39use serde::{
40    Deserialize,
41    Serialize,
42};
43
44use crate::RetryDelay;
45use crate::constants::DEFAULT_RETRY_JITTER;
46
47/// Jitter strategy applied after a base [`crate::RetryDelay`] has been calculated.
48///
49/// Supports [`RetryJitter::None`] and symmetric [`RetryJitter::Factor`] jitter.
50/// After randomization, delays are clamped to **non-negative** values.
51#[derive(Debug, Clone, Copy, PartialEq, Display, DeriveFromStr, Serialize, Deserialize)]
52pub enum RetryJitter {
53    /// No jitter: [`RetryJitter::apply`] returns the base delay unchanged.
54    #[display("none")]
55    #[from_str(regex = r"(?i)\s*none\s*")]
56    None,
57
58    /// Symmetric relative jitter around the base delay.
59    ///
60    /// The inner `f64` is the relative half-span: jitter is drawn uniformly from
61    /// `[-base * factor, base * factor]` nanoseconds (see [`RetryJitter::apply`]).
62    /// It must be finite and lie in **`[0.0, 1.0]`** for validated configurations.
63    #[display("factor:{0}")]
64    #[from_str(regex = r"\s*factor:\s*(?<0>\S(?:.*\S)?)\s*")]
65    Factor(#[display(with = RetryJitterFactorFormat)] f64),
66}
67
68/// Formats jitter factors as `f64` text and parses with range validation.
69struct RetryJitterFactorFormat;
70
71impl DisplayFormat<f64> for RetryJitterFactorFormat {
72    /// Writes the factor using the default `f64` formatter.
73    ///
74    /// # Parameters
75    /// - `f`: Output formatter.
76    /// - `value`: Factor value.
77    ///
78    /// # Returns
79    /// `Ok(())` on success, or [`std::fmt::Error`] if formatting fails.
80    ///
81    /// # Errors
82    /// Returns [`std::fmt::Error`] only if the formatter rejects output.
83    fn write(&self, f: &mut std::fmt::Formatter<'_>, value: &f64) -> std::fmt::Result {
84        write!(f, "{value}")
85    }
86}
87
88impl FromStrFormat<f64> for RetryJitterFactorFormat {
89    /// Error returned by factor parsing.
90    type Err = ParseError;
91
92    /// Parses and validates a factor in range `[0.0, 1.0]`.
93    ///
94    /// # Parameters
95    /// - `s`: Raw factor text captured by `parse-display`.
96    ///
97    /// # Returns
98    /// The parsed factor.
99    ///
100    /// # Errors
101    /// Returns [`ParseError`] when the input is not a valid `f64` or lies outside
102    /// `[0.0, 1.0]`, including non-finite values.
103    fn parse(&self, s: &str) -> Result<f64, Self::Err> {
104        let value = s
105            .parse::<f64>()
106            .map_err(|_| ParseError::with_message("invalid retry jitter factor"))?;
107        if !(0.0..=1.0).contains(&value) {
108            return Err(ParseError::with_message(
109                "retry jitter factor must be in range [0.0, 1.0]",
110            ));
111        }
112        Ok(value)
113    }
114}
115
116impl RetryJitter {
117    /// Creates a no-jitter strategy.
118    ///
119    /// # Parameters
120    /// This function has no parameters.
121    ///
122    /// # Returns
123    /// A [`RetryJitter::None`] strategy.
124    ///
125    /// # Errors
126    /// This function does not return errors.
127    #[inline]
128    pub fn none() -> Self {
129        Self::None
130    }
131
132    /// Creates a symmetric relative jitter strategy.
133    ///
134    /// Validation requires `factor` to be finite and within `[0.0, 1.0]`.
135    ///
136    /// # Parameters
137    /// - `factor`: Relative jitter range. For example, `0.2` samples from
138    ///   `base +/- 20%`.
139    ///
140    /// # Returns
141    /// A [`RetryJitter::Factor`] strategy.
142    ///
143    /// # Errors
144    /// This constructor does not validate `factor`; use [`RetryJitter::validate`]
145    /// before applying values that come from configuration or user input.
146    #[inline]
147    pub fn factor(factor: f64) -> Self {
148        Self::Factor(factor)
149    }
150
151    /// Applies jitter to a base delay.
152    ///
153    /// For [`RetryJitter::None`], returns `base` unchanged.
154    ///
155    /// For [`RetryJitter::Factor`], if `factor <= 0.0` or `base` is zero, returns
156    /// `base` unchanged. Otherwise draws a uniform sample from the inclusive range
157    /// `[-base * factor, base * factor]` in nanosecond space, adds it to the base,
158    /// then clamps the result to **at least zero** (truncating the sum to `u64`
159    /// nanoseconds). When `base` exceeds `u64::MAX` nanoseconds, this function
160    /// returns `base` unchanged to avoid lossy downcasts.
161    ///
162    /// # Parameters
163    /// - `base`: Base delay calculated by [`crate::RetryDelay`].
164    ///
165    /// # Returns
166    /// The jittered delay, never below zero.
167    ///
168    /// # Errors
169    /// This function does not return errors.
170    ///
171    /// # Panics
172    /// This function does not panic for non-finite factors. Non-finite values
173    /// gracefully fall back to returning `base`.
174    pub fn apply(&self, base: Duration) -> Duration {
175        match self {
176            Self::None => base,
177            Self::Factor(factor) if !factor.is_finite() || *factor <= 0.0 || base.is_zero() => base,
178            Self::Factor(factor) => {
179                let base_nanos_u128 = base.as_nanos();
180                if base_nanos_u128 > u64::MAX as u128 {
181                    return base;
182                }
183                let base_nanos = base_nanos_u128 as f64;
184                let span = base_nanos * factor;
185                let mut rng = rand::rng();
186                let jitter = rng.random_range(-span..=span);
187                let nanos = (base_nanos + jitter).clamp(0.0, u64::MAX as f64) as u64;
188                Duration::from_nanos(nanos)
189            }
190        }
191    }
192
193    /// Calculates and jitters the delay for one retry attempt.
194    ///
195    /// This method combines base-delay strategy selection and jitter application
196    /// into one step.
197    ///
198    /// # Parameters
199    /// - `delay_strategy`: Base delay strategy used to calculate the attempt
200    ///   delay.
201    /// - `attempt`: Failed-attempt index passed to
202    ///   [`RetryDelay::base_delay`].
203    ///
204    /// # Returns
205    /// The delay for the attempt after jitter is applied.
206    ///
207    /// # Errors
208    /// This function does not return errors.
209    ///
210    /// # Panics
211    /// This function does not panic for non-finite factors. Non-finite values
212    /// gracefully fall back to returning the base delay.
213    pub fn delay_for_attempt(&self, delay_strategy: &RetryDelay, attempt: u32) -> Duration {
214        let base_delay = delay_strategy.base_delay(attempt);
215        self.apply(base_delay)
216    }
217
218    /// Validates jitter parameters for use with executors and options.
219    ///
220    /// [`RetryJitter::None`] is always valid. For [`RetryJitter::Factor`], the factor
221    /// must be finite and satisfy **`0.0 <= factor <= 1.0`** (endpoints included).
222    ///
223    /// # Returns
224    /// `Ok(())` when the jitter configuration is usable.
225    ///
226    /// # Parameters
227    /// This method has no parameters.
228    ///
229    /// # Errors
230    /// Returns an error when the factor is negative, greater than `1.0`, NaN,
231    /// or infinite.
232    pub fn validate(&self) -> Result<(), String> {
233        match self {
234            Self::None => Ok(()),
235            Self::Factor(factor) => {
236                if !factor.is_finite() || *factor < 0.0 || *factor > 1.0 {
237                    Err("jitter factor must be finite and in range [0.0, 1.0]".to_string())
238                } else {
239                    Ok(())
240                }
241            }
242        }
243    }
244}
245
246impl Default for RetryJitter {
247    /// Creates the default jitter strategy.
248    ///
249    /// # Returns
250    /// The value obtained by parsing [`crate::constants::DEFAULT_RETRY_JITTER`]
251    /// using [`RetryJitter::from_str`].
252    ///
253    /// # Parameters
254    /// This function has no parameters.
255    ///
256    /// # Errors
257    /// This function does not return errors.
258    ///
259    /// # Panics
260    /// Panics if [`crate::constants::DEFAULT_RETRY_JITTER`] is not a valid
261    /// [`RetryJitter`] string. That indicates a crate bug, not a caller mistake.
262    #[inline]
263    fn default() -> Self {
264        Self::from_str(DEFAULT_RETRY_JITTER)
265            .expect("DEFAULT_RETRY_JITTER must be a valid RetryJitter string")
266    }
267}