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::Retry`].
12//! Raw config merge logic lives in [`crate::options::retry_config_values`].
13//!
14//! Author: Haixing Hu
15
16use std::num::NonZeroU32;
17use std::time::Duration;
18
19#[cfg(feature = "config")]
20use qubit_config::ConfigReader;
21
22use super::attempt_timeout_option::AttemptTimeoutOption;
23#[cfg(feature = "config")]
24use super::retry_config_values::RetryConfigValues;
25
26use crate::constants::{
27    DEFAULT_RETRY_MAX_ATTEMPTS, DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
28    DEFAULT_RETRY_MAX_TOTAL_ELAPSED, DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS,
29    KEY_ATTEMPT_TIMEOUT_MILLIS, KEY_DELAY, KEY_JITTER_FACTOR, KEY_MAX_ATTEMPTS,
30};
31use crate::{RetryConfigError, RetryDelay, RetryJitter};
32
33/// Immutable retry option snapshot used by [`crate::Retry`].
34///
35/// `RetryOptions` owns all executor configuration that is independent of the
36/// application error type: attempt limits, elapsed budgets, delay strategy, and
37/// jitter strategy. Construction validates the delay and jitter values before
38/// an executor can use them.
39///
40/// Author: Haixing Hu
41#[derive(Debug, Clone, PartialEq)]
42pub struct RetryOptions {
43    /// Maximum attempts, including the initial attempt.
44    pub(crate) max_attempts: NonZeroU32,
45    /// Maximum cumulative user operation time for the retry flow.
46    pub(crate) max_operation_elapsed: Option<Duration>,
47    /// Maximum monotonic elapsed time for the whole retry flow.
48    pub(crate) max_total_elapsed: Option<Duration>,
49    /// Base delay strategy between attempts.
50    pub(crate) delay: RetryDelay,
51    /// RetryJitter applied to each base delay.
52    pub(crate) jitter: RetryJitter,
53    /// Optional per-attempt timeout settings.
54    pub(crate) attempt_timeout: Option<AttemptTimeoutOption>,
55    /// Grace period for a timed-out worker to observe cancellation and exit.
56    pub(crate) worker_cancel_grace: Duration,
57}
58
59impl RetryOptions {
60    /// Returns maximum attempts, including the initial attempt.
61    ///
62    /// # Parameters
63    /// This method has no parameters.
64    ///
65    /// # Returns
66    /// Maximum attempts configured for one retry execution.
67    ///
68    /// # Errors
69    /// This method does not return errors.
70    #[inline]
71    pub fn max_attempts(&self) -> u32 {
72        self.max_attempts.get()
73    }
74
75    /// Returns maximum cumulative user operation time budget.
76    ///
77    /// # Parameters
78    /// This method has no parameters.
79    ///
80    /// # Returns
81    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
82    ///
83    /// # Errors
84    /// This method does not return errors.
85    #[inline]
86    pub fn max_operation_elapsed(&self) -> Option<Duration> {
87        self.max_operation_elapsed
88    }
89
90    /// Returns maximum total retry-flow elapsed time budget.
91    ///
92    /// This budget is measured with monotonic time and includes operation
93    /// execution, retry sleeps, retry-after sleeps, and retry control-path
94    /// listener time.
95    ///
96    /// # Parameters
97    /// This method has no parameters.
98    ///
99    /// # Returns
100    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
101    ///
102    /// # Errors
103    /// This method does not return errors.
104    #[inline]
105    pub fn max_total_elapsed(&self) -> Option<Duration> {
106        self.max_total_elapsed
107    }
108
109    /// Returns the base delay strategy.
110    ///
111    /// # Parameters
112    /// This method has no parameters.
113    ///
114    /// # Returns
115    /// Borrowed delay strategy used by the executor.
116    ///
117    /// # Errors
118    /// This method does not return errors.
119    #[inline]
120    pub fn delay(&self) -> &RetryDelay {
121        &self.delay
122    }
123
124    /// Returns the jitter strategy.
125    ///
126    /// # Parameters
127    /// This method has no parameters.
128    ///
129    /// # Returns
130    /// Jitter strategy used by the executor.
131    ///
132    /// # Errors
133    /// This method does not return errors.
134    #[inline]
135    pub fn jitter(&self) -> RetryJitter {
136        self.jitter
137    }
138
139    /// Returns the optional per-attempt timeout settings.
140    ///
141    /// # Parameters
142    /// This method has no parameters.
143    ///
144    /// # Returns
145    /// `Some(AttemptTimeoutOption)` when per-attempt timeout is configured.
146    ///
147    /// # Errors
148    /// This method does not return errors.
149    #[inline]
150    pub fn attempt_timeout(&self) -> Option<AttemptTimeoutOption> {
151        self.attempt_timeout
152    }
153
154    /// Returns the worker cancellation grace period.
155    ///
156    /// # Parameters
157    /// This method has no parameters.
158    ///
159    /// # Returns
160    /// Duration the worker-thread executor waits after requesting cooperative
161    /// cancellation for a timed-out worker attempt.
162    #[inline]
163    pub fn worker_cancel_grace(&self) -> Duration {
164        self.worker_cancel_grace
165    }
166
167    /// Creates and validates a retry option snapshot.
168    ///
169    /// # Parameters
170    /// - `max_attempts`: Maximum number of attempts, including the first call.
171    ///   Must be greater than zero.
172    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
173    ///   attempts. Listener execution and retry sleeps are excluded.
174    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
175    ///   whole retry flow. Operation execution, retry sleeps, retry-after
176    ///   sleeps, and retry control-path listener time are included.
177    /// - `delay`: Base delay strategy used between attempts.
178    /// - `jitter`: RetryJitter strategy applied to each base delay.
179    ///
180    /// # Returns
181    /// A validated [`RetryOptions`] value.
182    ///
183    /// # Errors
184    /// Returns [`RetryConfigError`] when `max_attempts` is zero, or when
185    /// `delay` or `jitter` contains invalid parameters.
186    pub fn new(
187        max_attempts: u32,
188        max_operation_elapsed: Option<Duration>,
189        max_total_elapsed: Option<Duration>,
190        delay: RetryDelay,
191        jitter: RetryJitter,
192    ) -> Result<Self, RetryConfigError> {
193        Self::new_with_attempt_timeout(
194            max_attempts,
195            max_operation_elapsed,
196            max_total_elapsed,
197            delay,
198            jitter,
199            None,
200        )
201    }
202
203    /// Creates and validates a retry option snapshot with attempt timeout.
204    ///
205    /// # Parameters
206    /// - `max_attempts`: Maximum number of attempts, including the first call.
207    ///   Must be greater than zero.
208    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
209    ///   attempts. Listener execution and retry sleeps are excluded.
210    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
211    ///   whole retry flow. Operation execution, retry sleeps, retry-after
212    ///   sleeps, and retry control-path listener time are included.
213    /// - `delay`: Base delay strategy used between attempts.
214    /// - `jitter`: RetryJitter strategy applied to each base delay.
215    /// - `attempt_timeout`: Optional per-attempt timeout settings.
216    ///
217    /// # Returns
218    /// A validated [`RetryOptions`] value.
219    ///
220    /// # Errors
221    /// Returns [`RetryConfigError`] when `max_attempts` is zero, when delay or
222    /// jitter contains invalid parameters, or when the attempt timeout is zero.
223    pub fn new_with_attempt_timeout(
224        max_attempts: u32,
225        max_operation_elapsed: Option<Duration>,
226        max_total_elapsed: Option<Duration>,
227        delay: RetryDelay,
228        jitter: RetryJitter,
229        attempt_timeout: Option<AttemptTimeoutOption>,
230    ) -> Result<Self, RetryConfigError> {
231        let max_attempts = NonZeroU32::new(max_attempts).ok_or_else(|| {
232            RetryConfigError::invalid_value(
233                KEY_MAX_ATTEMPTS,
234                "max_attempts must be greater than zero",
235            )
236        })?;
237        let options = Self {
238            max_attempts,
239            max_operation_elapsed,
240            max_total_elapsed,
241            delay,
242            jitter,
243            attempt_timeout,
244            worker_cancel_grace: Duration::from_millis(DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS),
245        };
246        options.validate()?;
247        Ok(options)
248    }
249
250    /// Reads a retry option snapshot from a `ConfigReader`.
251    ///
252    /// Keys are relative to the reader. Use `config.prefix_view("retry")` when
253    /// the retry settings are nested under a `retry.` prefix.
254    ///
255    /// # Parameters
256    /// - `config`: Configuration reader whose keys are relative to the retry
257    ///   configuration prefix.
258    ///
259    /// # Returns
260    /// A validated [`RetryOptions`] value. Missing keys fall back to
261    /// [`RetryOptions::default`].
262    ///
263    /// # Errors
264    /// Returns [`RetryConfigError`] when a key cannot be read as the expected
265    /// type, the delay strategy name is unsupported, or the resulting options
266    /// fail validation.
267    #[cfg(feature = "config")]
268    pub fn from_config<R>(config: &R) -> Result<Self, RetryConfigError>
269    where
270        R: ConfigReader + ?Sized,
271    {
272        let default = Self::default();
273        let values = RetryConfigValues::new(config).map_err(RetryConfigError::from)?;
274        values.to_options(&default)
275    }
276
277    /// Validates all options.
278    ///
279    /// # Returns
280    /// `Ok(())` when all contained strategy parameters are usable.
281    ///
282    /// # Parameters
283    /// This method has no parameters.
284    ///
285    /// # Errors
286    /// Returns [`RetryConfigError`] with the relevant config key when the delay
287    /// or jitter strategy is invalid.
288    pub fn validate(&self) -> Result<(), RetryConfigError> {
289        self.delay
290            .validate()
291            .map_err(|message| RetryConfigError::invalid_value(KEY_DELAY, message))?;
292        self.jitter
293            .validate()
294            .map_err(|message| RetryConfigError::invalid_value(KEY_JITTER_FACTOR, message))?;
295        if let Some(attempt_timeout) = self.attempt_timeout {
296            attempt_timeout.validate().map_err(|message| {
297                RetryConfigError::invalid_value(KEY_ATTEMPT_TIMEOUT_MILLIS, message)
298            })?;
299        }
300        Ok(())
301    }
302
303    /// Calculates the base retry delay for one failed-attempt index.
304    ///
305    /// # Parameters
306    /// - `attempt`: Failed-attempt index, starting at 1.
307    ///
308    /// # Returns
309    /// Base delay before jitter.
310    pub fn base_delay_for_attempt(&self, attempt: u32) -> Duration {
311        self.delay.base_delay(attempt)
312    }
313
314    /// Calculates the retry delay for one failed-attempt index after jitter.
315    ///
316    /// # Parameters
317    /// - `attempt`: Failed-attempt index, starting at 1.
318    ///
319    /// # Returns
320    /// Delay after jitter is applied.
321    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
322        self.jitter.delay_for_attempt(&self.delay, attempt)
323    }
324
325    /// Calculates the next base delay from the current base delay.
326    ///
327    /// For exponential delay, this advances by one multiplier step from
328    /// `current` and caps at `max`. For other strategies, this delegates to the
329    /// strategy's per-attempt base behavior.
330    ///
331    /// # Parameters
332    /// - `current`: Current base delay before jitter.
333    ///
334    /// # Returns
335    /// Next base delay before jitter.
336    pub fn next_base_delay_from_current(&self, current: Duration) -> Duration {
337        match &self.delay {
338            RetryDelay::None => Duration::ZERO,
339            RetryDelay::Fixed(delay) => *delay,
340            RetryDelay::Random { .. } => self.delay.base_delay(1),
341            RetryDelay::Exponential {
342                max, multiplier, ..
343            } => {
344                let bounded_current = current.min(*max);
345                let next = bounded_current.mul_f64(*multiplier);
346                if next > *max { *max } else { next }
347            }
348        }
349    }
350
351    /// Applies configured jitter to `base_delay`.
352    ///
353    /// # Parameters
354    /// - `base_delay`: Base delay before jitter.
355    ///
356    /// # Returns
357    /// Delay after jitter.
358    pub fn jittered_delay(&self, base_delay: Duration) -> Duration {
359        self.jitter.apply(base_delay)
360    }
361
362    /// Calculates the next delay from the current base delay and applies jitter.
363    ///
364    /// # Parameters
365    /// - `current`: Current base delay before jitter.
366    ///
367    /// # Returns
368    /// Next delay after jitter.
369    pub fn next_delay_from_current(&self, current: Duration) -> Duration {
370        self.jittered_delay(self.next_base_delay_from_current(current))
371    }
372}
373
374impl Default for RetryOptions {
375    /// Creates the default retry options.
376    ///
377    /// # Returns
378    /// Options with five attempts, no cumulative user operation time limit,
379    /// exponential delay, no jitter, and the default worker cancellation grace.
380    ///
381    /// # Parameters
382    /// This function has no parameters.
383    ///
384    /// # Errors
385    /// This function does not return errors.
386    ///
387    /// # Panics
388    /// This function does not panic because the hard-coded attempt count is
389    /// non-zero.
390    #[inline]
391    fn default() -> Self {
392        Self {
393            max_attempts: NonZeroU32::new(DEFAULT_RETRY_MAX_ATTEMPTS)
394                .expect("default retry attempts must be non-zero"),
395            max_operation_elapsed: DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
396            max_total_elapsed: DEFAULT_RETRY_MAX_TOTAL_ELAPSED,
397            delay: RetryDelay::default(),
398            jitter: RetryJitter::default(),
399            attempt_timeout: None,
400            worker_cancel_grace: Duration::from_millis(DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS),
401        }
402    }
403}