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}