Skip to main content

qubit_retry/options/
retry_options.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026.
4 *    Haixing Hu, Qubit Co. Ltd.
5 *
6 *    All rights reserved.
7 *
8 ******************************************************************************/
9//! Retry option snapshot and configuration loading helpers.
10//!
11//! This module contains the immutable options consumed by [`crate::RetryExecutor`]
12//! and the private helpers that translate `qubit-config` values into strongly
13//! typed retry settings.
14
15use std::num::NonZeroU32;
16use std::time::Duration;
17
18use qubit_config::{ConfigReader, ConfigResult};
19
20use crate::{RetryConfigError, RetryDelay, RetryJitter};
21
22/// Immutable retry option snapshot used by [`crate::RetryExecutor`].
23///
24/// `RetryOptions` owns all executor configuration that is independent of the
25/// application error type: attempt limits, total elapsed-time budget, delay
26/// strategy, and jitter strategy. Construction validates the delay and jitter
27/// values before an executor can use them.
28#[derive(Debug, Clone, PartialEq)]
29pub struct RetryOptions {
30    /// Maximum attempts, including the initial attempt.
31    pub max_attempts: NonZeroU32,
32    /// Maximum total elapsed time for the retry flow, in milliseconds.
33    pub max_elapsed: Option<Duration>,
34    /// Base delay strategy between attempts.
35    pub delay: RetryDelay,
36    /// RetryJitter applied to each base delay.
37    pub jitter: RetryJitter,
38}
39
40impl RetryOptions {
41    /// Key for maximum attempts.
42    pub const KEY_MAX_ATTEMPTS: &'static str = "max_attempts";
43    /// Key for maximum elapsed budget in milliseconds. Missing means unlimited;
44    /// zero also maps to unlimited when read from config.
45    pub const KEY_MAX_ELAPSED_MILLIS: &'static str = "max_elapsed_millis";
46    /// Key for delay strategy name.
47    pub const KEY_DELAY: &'static str = "delay";
48    /// Backward-compatible alias for delay strategy name.
49    pub const KEY_DELAY_STRATEGY: &'static str = "delay_strategy";
50    /// Key for fixed delay in milliseconds.
51    pub const KEY_FIXED_DELAY_MILLIS: &'static str = "fixed_delay_millis";
52    /// Key for random minimum delay in milliseconds.
53    pub const KEY_RANDOM_MIN_DELAY_MILLIS: &'static str = "random_min_delay_millis";
54    /// Key for random maximum delay in milliseconds.
55    pub const KEY_RANDOM_MAX_DELAY_MILLIS: &'static str = "random_max_delay_millis";
56    /// Key for exponential initial delay in milliseconds.
57    pub const KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS: &'static str =
58        "exponential_initial_delay_millis";
59    /// Key for exponential maximum delay in milliseconds.
60    pub const KEY_EXPONENTIAL_MAX_DELAY_MILLIS: &'static str = "exponential_max_delay_millis";
61    /// Key for exponential multiplier.
62    pub const KEY_EXPONENTIAL_MULTIPLIER: &'static str = "exponential_multiplier";
63    /// Key for jitter factor.
64    pub const KEY_JITTER_FACTOR: &'static str = "jitter_factor";
65
66    /// Creates and validates a retry option snapshot.
67    ///
68    /// # Parameters
69    /// - `max_attempts`: Maximum number of attempts, including the first call.
70    ///   Must be greater than zero.
71    /// - `max_elapsed`: Optional total elapsed-time budget for all attempts
72    ///   and sleeps.
73    /// - `delay`: Base delay strategy used between attempts.
74    /// - `jitter`: RetryJitter strategy applied to each base delay.
75    ///
76    /// # Returns
77    /// A validated [`RetryOptions`] value.
78    ///
79    /// # Errors
80    /// Returns [`RetryConfigError`] when `max_attempts` is zero, or when
81    /// `delay` or `jitter` contains invalid parameters.
82    pub fn new(
83        max_attempts: u32,
84        max_elapsed: Option<Duration>,
85        delay: RetryDelay,
86        jitter: RetryJitter,
87    ) -> Result<Self, RetryConfigError> {
88        let max_attempts = NonZeroU32::new(max_attempts).ok_or_else(|| {
89            RetryConfigError::invalid_value(
90                Self::KEY_MAX_ATTEMPTS,
91                "max_attempts must be greater than zero",
92            )
93        })?;
94        let options = Self {
95            max_attempts,
96            max_elapsed,
97            delay,
98            jitter,
99        };
100        options.validate()?;
101        Ok(options)
102    }
103
104    /// Reads a retry option snapshot from a `ConfigReader`.
105    ///
106    /// Keys are relative to the reader. Use `config.prefix_view("retry")` when
107    /// the retry settings are nested under a `retry.` prefix.
108    ///
109    /// # Parameters
110    /// - `config`: Configuration reader whose keys are relative to the retry
111    ///   configuration prefix.
112    ///
113    /// # Returns
114    /// A validated [`RetryOptions`] value. Missing keys fall back to
115    /// [`RetryOptions::default`].
116    ///
117    /// # Errors
118    /// Returns [`RetryConfigError`] when a key cannot be read as the expected
119    /// type, the delay strategy name is unsupported, or the resulting options
120    /// fail validation.
121    pub fn from_config<R>(config: &R) -> Result<Self, RetryConfigError>
122    where
123        R: ConfigReader + ?Sized,
124    {
125        let default = Self::default();
126        let values = RetryConfigValues::read_from(config).map_err(RetryConfigError::from)?;
127        values.to_options(&default)
128    }
129
130    /// Validates all options.
131    ///
132    /// # Returns
133    /// `Ok(())` when all contained strategy parameters are usable.
134    ///
135    /// # Parameters
136    /// This method has no parameters.
137    ///
138    /// # Errors
139    /// Returns [`RetryConfigError`] with the relevant config key when the delay
140    /// or jitter strategy is invalid.
141    pub fn validate(&self) -> Result<(), RetryConfigError> {
142        self.delay
143            .validate()
144            .map_err(|message| RetryConfigError::invalid_value(Self::KEY_DELAY, message))?;
145        self.jitter
146            .validate()
147            .map_err(|message| RetryConfigError::invalid_value(Self::KEY_JITTER_FACTOR, message))?;
148        Ok(())
149    }
150}
151
152impl Default for RetryOptions {
153    /// Creates the default retry options.
154    ///
155    /// # Returns
156    /// Options with three attempts, no total elapsed-time limit, exponential
157    /// delay, and no jitter.
158    ///
159    /// # Parameters
160    /// This function has no parameters.
161    ///
162    /// # Errors
163    /// This function does not return errors.
164    ///
165    /// # Panics
166    /// This function does not panic because the hard-coded attempt count is
167    /// non-zero.
168    #[inline]
169    fn default() -> Self {
170        Self {
171            max_attempts: NonZeroU32::new(3).expect("default retry attempts must be non-zero"),
172            max_elapsed: None,
173            delay: RetryDelay::default(),
174            jitter: RetryJitter::None,
175        }
176    }
177}
178
179/// Raw retry configuration values read from `qubit-config`.
180///
181/// This struct deliberately keeps all `ConfigReader` calls in one place. The
182/// conversion from `qubit-config` errors to retry-specific errors happens at
183/// the caller boundary, while the remaining methods only translate already
184/// typed values into retry domain objects.
185#[derive(Debug, Clone, PartialEq)]
186struct RetryConfigValues {
187    /// Optional maximum attempts value.
188    max_attempts: Option<u32>,
189    /// Optional elapsed-time budget in milliseconds.
190    max_elapsed_millis: Option<u64>,
191    /// Optional primary delay strategy name.
192    delay: Option<String>,
193    /// Optional backward-compatible delay strategy alias.
194    delay_strategy: Option<String>,
195    /// Optional fixed delay in milliseconds.
196    fixed_delay_millis: Option<u64>,
197    /// Optional random delay lower bound in milliseconds.
198    random_min_delay_millis: Option<u64>,
199    /// Optional random delay upper bound in milliseconds.
200    random_max_delay_millis: Option<u64>,
201    /// Optional exponential initial delay in milliseconds.
202    exponential_initial_delay_millis: Option<u64>,
203    /// Optional exponential maximum delay in milliseconds.
204    exponential_max_delay_millis: Option<u64>,
205    /// Optional exponential multiplier.
206    exponential_multiplier: Option<f64>,
207    /// Optional jitter factor.
208    jitter_factor: Option<f64>,
209}
210
211impl RetryConfigValues {
212    /// Reads all retry-related configuration values.
213    ///
214    /// # Parameters
215    /// - `config`: Configuration reader whose keys are relative to the retry
216    ///   configuration prefix.
217    ///
218    /// # Returns
219    /// A [`RetryConfigValues`] snapshot containing every retry option key
220    /// understood by this crate.
221    ///
222    /// # Errors
223    /// Returns `qubit-config`'s `ConfigError` through [`ConfigResult`] when any
224    /// present key cannot be read as the expected type or string substitution
225    /// fails.
226    fn read_from<R>(config: &R) -> ConfigResult<Self>
227    where
228        R: ConfigReader + ?Sized,
229    {
230        Ok(Self {
231            max_attempts: config.get_optional(RetryOptions::KEY_MAX_ATTEMPTS)?,
232            max_elapsed_millis: config.get_optional(RetryOptions::KEY_MAX_ELAPSED_MILLIS)?,
233            delay: config.get_optional_string(RetryOptions::KEY_DELAY)?,
234            delay_strategy: config.get_optional_string(RetryOptions::KEY_DELAY_STRATEGY)?,
235            fixed_delay_millis: config.get_optional(RetryOptions::KEY_FIXED_DELAY_MILLIS)?,
236            random_min_delay_millis: config
237                .get_optional(RetryOptions::KEY_RANDOM_MIN_DELAY_MILLIS)?,
238            random_max_delay_millis: config
239                .get_optional(RetryOptions::KEY_RANDOM_MAX_DELAY_MILLIS)?,
240            exponential_initial_delay_millis: config
241                .get_optional(RetryOptions::KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS)?,
242            exponential_max_delay_millis: config
243                .get_optional(RetryOptions::KEY_EXPONENTIAL_MAX_DELAY_MILLIS)?,
244            exponential_multiplier: config
245                .get_optional(RetryOptions::KEY_EXPONENTIAL_MULTIPLIER)?,
246            jitter_factor: config.get_optional(RetryOptions::KEY_JITTER_FACTOR)?,
247        })
248    }
249
250    /// Converts the raw configuration snapshot into validated retry options.
251    ///
252    /// # Parameters
253    /// - `default`: Default options used when a config key is absent.
254    ///
255    /// # Returns
256    /// A validated [`RetryOptions`] value.
257    ///
258    /// # Errors
259    /// Returns [`RetryConfigError`] when the delay strategy name is unsupported
260    /// or the resulting options fail validation.
261    fn to_options(&self, default: &RetryOptions) -> Result<RetryOptions, RetryConfigError> {
262        let max_attempts = self.max_attempts.unwrap_or(default.max_attempts.get());
263        let max_elapsed = self.max_elapsed();
264        let delay = self.delay(default)?;
265        let jitter = self.jitter(default);
266        RetryOptions::new(max_attempts, max_elapsed, delay, jitter)
267    }
268
269    /// Resolves the elapsed-time budget.
270    ///
271    /// # Parameters
272    /// This method has no parameters.
273    ///
274    /// # Returns
275    /// `Some(Duration)` when `max_elapsed_millis` is present and non-zero;
276    /// otherwise `None`.
277    ///
278    /// # Errors
279    /// This method does not return errors.
280    fn max_elapsed(&self) -> Option<Duration> {
281        match self.max_elapsed_millis {
282            Some(0) | None => None,
283            Some(millis) => Some(Duration::from_millis(millis)),
284        }
285    }
286
287    /// Resolves the base delay strategy.
288    ///
289    /// # Parameters
290    /// - `default`: Default options used when neither explicit nor implicit
291    ///   delay configuration is present.
292    ///
293    /// # Returns
294    /// The explicit, implicit, or default [`RetryDelay`] strategy.
295    ///
296    /// # Errors
297    /// Returns [`RetryConfigError`] when the explicit delay strategy name is
298    /// unsupported.
299    fn delay(&self, default: &RetryOptions) -> Result<RetryDelay, RetryConfigError> {
300        let strategy = self
301            .delay
302            .as_deref()
303            .or(self.delay_strategy.as_deref())
304            .map(str::trim)
305            .map(|value| value.to_ascii_lowercase());
306        match strategy.as_deref() {
307            None => Ok(self
308                .implicit_delay()
309                .unwrap_or_else(|| default.delay.clone())),
310            Some("none") => Ok(RetryDelay::None),
311            Some("fixed") => Ok(RetryDelay::fixed(Duration::from_millis(
312                self.fixed_delay_millis.unwrap_or(1000),
313            ))),
314            Some("random") => Ok(RetryDelay::random(
315                Duration::from_millis(self.random_min_delay_millis.unwrap_or(1000)),
316                Duration::from_millis(self.random_max_delay_millis.unwrap_or(10000)),
317            )),
318            Some("exponential") | Some("exponential_backoff") => Ok(RetryDelay::exponential(
319                Duration::from_millis(self.exponential_initial_delay_millis.unwrap_or(1000)),
320                Duration::from_millis(self.exponential_max_delay_millis.unwrap_or(60000)),
321                self.exponential_multiplier.unwrap_or(2.0),
322            )),
323            Some(other) => Err(RetryConfigError::invalid_value(
324                RetryOptions::KEY_DELAY,
325                format!("unsupported delay strategy '{other}'"),
326            )),
327        }
328    }
329
330    /// Resolves a delay strategy from parameter keys when no strategy name is configured.
331    ///
332    /// # Parameters
333    /// This method has no parameters.
334    ///
335    /// # Returns
336    /// `Some(RetryDelay)` when any delay parameter key is present; otherwise `None`.
337    ///
338    /// # Errors
339    /// This method does not return errors because all config reads have already
340    /// succeeded.
341    fn implicit_delay(&self) -> Option<RetryDelay> {
342        if let Some(millis) = self.fixed_delay_millis {
343            return Some(RetryDelay::fixed(Duration::from_millis(millis)));
344        }
345        if self.random_min_delay_millis.is_some() || self.random_max_delay_millis.is_some() {
346            return Some(RetryDelay::random(
347                Duration::from_millis(self.random_min_delay_millis.unwrap_or(1000)),
348                Duration::from_millis(self.random_max_delay_millis.unwrap_or(10000)),
349            ));
350        }
351        if self.exponential_initial_delay_millis.is_some()
352            || self.exponential_max_delay_millis.is_some()
353            || self.exponential_multiplier.is_some()
354        {
355            return Some(RetryDelay::exponential(
356                Duration::from_millis(self.exponential_initial_delay_millis.unwrap_or(1000)),
357                Duration::from_millis(self.exponential_max_delay_millis.unwrap_or(60000)),
358                self.exponential_multiplier.unwrap_or(2.0),
359            ));
360        }
361        None
362    }
363
364    /// Resolves the jitter strategy.
365    ///
366    /// # Parameters
367    /// - `default`: Default options used when no jitter key is present or the
368    ///   configured jitter factor is `0.0`.
369    ///
370    /// # Returns
371    /// The configured or default [`RetryJitter`] strategy.
372    ///
373    /// # Errors
374    /// This method does not return errors. RetryJitter value validation is handled
375    /// by [`RetryOptions::new`].
376    fn jitter(&self, default: &RetryOptions) -> RetryJitter {
377        match self.jitter_factor {
378            Some(0.0) | None => default.jitter,
379            Some(factor) => RetryJitter::Factor(factor),
380        }
381    }
382}