Skip to main content

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