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