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