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
22#[cfg(feature = "config")]
23use super::retry_config_values::RetryConfigValues;
24use super::{
25    AttemptTimeoutOption,
26    EffectiveAttemptTimeout,
27};
28
29use crate::constants::{
30    DEFAULT_RETRY_MAX_ATTEMPTS,
31    DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
32    DEFAULT_RETRY_MAX_TOTAL_ELAPSED,
33    DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS,
34    KEY_ATTEMPT_TIMEOUT_MILLIS,
35    KEY_DELAY,
36    KEY_JITTER_FACTOR,
37    KEY_MAX_ATTEMPTS,
38};
39use crate::{
40    AttemptFailureDecision,
41    AttemptTimeoutSource,
42    RetryConfigError,
43    RetryDelay,
44    RetryErrorReason,
45    RetryJitter,
46};
47
48/// Immutable retry option snapshot used by [`crate::Retry`].
49///
50/// `RetryOptions` owns all executor configuration that is independent of the
51/// application error type: attempt limits, elapsed budgets, delay strategy, and
52/// jitter strategy. Construction validates the delay and jitter values before
53/// an executor can use them.
54///
55#[derive(Debug, Clone, PartialEq)]
56pub struct RetryOptions {
57    /// Maximum attempts, including the initial attempt.
58    pub(crate) max_attempts: NonZeroU32,
59    /// Maximum cumulative user operation time for the retry flow.
60    pub(crate) max_operation_elapsed: Option<Duration>,
61    /// Maximum monotonic elapsed time for the whole retry flow.
62    pub(crate) max_total_elapsed: Option<Duration>,
63    /// Base delay strategy between attempts.
64    pub(crate) delay: RetryDelay,
65    /// RetryJitter applied to each base delay.
66    pub(crate) jitter: RetryJitter,
67    /// Optional per-attempt timeout settings.
68    pub(crate) attempt_timeout: Option<AttemptTimeoutOption>,
69    /// Grace period for a timed-out worker to observe cancellation and exit.
70    pub(crate) worker_cancel_grace: Duration,
71}
72
73impl RetryOptions {
74    /// Returns maximum attempts, including the initial attempt.
75    ///
76    /// # Parameters
77    /// This method has no parameters.
78    ///
79    /// # Returns
80    /// Maximum attempts configured for one retry execution.
81    ///
82    /// # Errors
83    /// This method does not return errors.
84    #[inline]
85    pub fn max_attempts(&self) -> u32 {
86        self.max_attempts.get()
87    }
88
89    /// Returns maximum cumulative user operation time budget.
90    ///
91    /// # Parameters
92    /// This method has no parameters.
93    ///
94    /// # Returns
95    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
96    ///
97    /// # Errors
98    /// This method does not return errors.
99    #[inline]
100    pub fn max_operation_elapsed(&self) -> Option<Duration> {
101        self.max_operation_elapsed
102    }
103
104    /// Returns maximum total retry-flow elapsed time budget.
105    ///
106    /// This budget is measured with monotonic time and includes operation
107    /// execution, retry sleeps, retry-after sleeps, and retry control-path
108    /// listener time.
109    ///
110    /// # Parameters
111    /// This method has no parameters.
112    ///
113    /// # Returns
114    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
115    ///
116    /// # Errors
117    /// This method does not return errors.
118    #[inline]
119    pub fn max_total_elapsed(&self) -> Option<Duration> {
120        self.max_total_elapsed
121    }
122
123    /// Returns the base delay strategy.
124    ///
125    /// # Parameters
126    /// This method has no parameters.
127    ///
128    /// # Returns
129    /// Borrowed delay strategy used by the executor.
130    ///
131    /// # Errors
132    /// This method does not return errors.
133    #[inline]
134    pub fn delay(&self) -> &RetryDelay {
135        &self.delay
136    }
137
138    /// Returns the jitter strategy.
139    ///
140    /// # Parameters
141    /// This method has no parameters.
142    ///
143    /// # Returns
144    /// Jitter strategy used by the executor.
145    ///
146    /// # Errors
147    /// This method does not return errors.
148    #[inline]
149    pub fn jitter(&self) -> RetryJitter {
150        self.jitter
151    }
152
153    /// Returns the optional per-attempt timeout settings.
154    ///
155    /// # Parameters
156    /// This method has no parameters.
157    ///
158    /// # Returns
159    /// `Some(AttemptTimeoutOption)` when per-attempt timeout is configured.
160    ///
161    /// # Errors
162    /// This method does not return errors.
163    #[inline]
164    pub fn attempt_timeout(&self) -> Option<AttemptTimeoutOption> {
165        self.attempt_timeout
166    }
167
168    /// Returns the worker cancellation grace period.
169    ///
170    /// # Parameters
171    /// This method has no parameters.
172    ///
173    /// # Returns
174    /// Duration the worker-thread executor waits after requesting cooperative
175    /// cancellation for a timed-out worker attempt.
176    #[inline]
177    pub fn worker_cancel_grace(&self) -> Duration {
178        self.worker_cancel_grace
179    }
180
181    /// Creates and validates a retry option snapshot.
182    ///
183    /// # Parameters
184    /// - `max_attempts`: Maximum number of attempts, including the first call.
185    ///   Must be greater than zero.
186    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
187    ///   attempts. Listener execution and retry sleeps are excluded.
188    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
189    ///   whole retry flow. Operation execution, retry sleeps, retry-after
190    ///   sleeps, and retry control-path listener time are included.
191    /// - `delay`: Base delay strategy used between attempts.
192    /// - `jitter`: RetryJitter strategy applied to each base delay.
193    ///
194    /// # Returns
195    /// A validated [`RetryOptions`] value.
196    ///
197    /// # Errors
198    /// Returns [`RetryConfigError`] when `max_attempts` is zero, or when
199    /// `delay` or `jitter` contains invalid parameters.
200    pub fn new(
201        max_attempts: u32,
202        max_operation_elapsed: Option<Duration>,
203        max_total_elapsed: Option<Duration>,
204        delay: RetryDelay,
205        jitter: RetryJitter,
206    ) -> Result<Self, RetryConfigError> {
207        Self::new_with_attempt_timeout(
208            max_attempts,
209            max_operation_elapsed,
210            max_total_elapsed,
211            delay,
212            jitter,
213            None,
214        )
215    }
216
217    /// Creates and validates a retry option snapshot with attempt timeout.
218    ///
219    /// # Parameters
220    /// - `max_attempts`: Maximum number of attempts, including the first call.
221    ///   Must be greater than zero.
222    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
223    ///   attempts. Listener execution and retry sleeps are excluded.
224    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
225    ///   whole retry flow. Operation execution, retry sleeps, retry-after
226    ///   sleeps, and retry control-path listener time are included.
227    /// - `delay`: Base delay strategy used between attempts.
228    /// - `jitter`: RetryJitter strategy applied to each base delay.
229    /// - `attempt_timeout`: Optional per-attempt timeout settings.
230    ///
231    /// # Returns
232    /// A validated [`RetryOptions`] value.
233    ///
234    /// # Errors
235    /// Returns [`RetryConfigError`] when `max_attempts` is zero, when delay or
236    /// jitter contains invalid parameters, or when the attempt timeout is zero.
237    pub fn new_with_attempt_timeout(
238        max_attempts: u32,
239        max_operation_elapsed: Option<Duration>,
240        max_total_elapsed: Option<Duration>,
241        delay: RetryDelay,
242        jitter: RetryJitter,
243        attempt_timeout: Option<AttemptTimeoutOption>,
244    ) -> Result<Self, RetryConfigError> {
245        let max_attempts = NonZeroU32::new(max_attempts).ok_or_else(|| {
246            RetryConfigError::invalid_value(
247                KEY_MAX_ATTEMPTS,
248                "max_attempts must be greater than zero",
249            )
250        })?;
251        let options = Self {
252            max_attempts,
253            max_operation_elapsed,
254            max_total_elapsed,
255            delay,
256            jitter,
257            attempt_timeout,
258            worker_cancel_grace: Duration::from_millis(DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS),
259        };
260        options.validate()?;
261        Ok(options)
262    }
263
264    /// Reads a retry option snapshot from a `ConfigReader`.
265    ///
266    /// Keys are relative to the reader. Use `config.prefix_view("retry")` when
267    /// the retry settings are nested under a `retry.` prefix.
268    ///
269    /// # Parameters
270    /// - `config`: Configuration reader whose keys are relative to the retry
271    ///   configuration prefix.
272    ///
273    /// # Returns
274    /// A validated [`RetryOptions`] value. Missing keys fall back to
275    /// [`RetryOptions::default`].
276    ///
277    /// # Errors
278    /// Returns [`RetryConfigError`] when a key cannot be read as the expected
279    /// type, the delay strategy name is unsupported, or the resulting options
280    /// fail validation.
281    #[cfg(feature = "config")]
282    pub fn from_config<R>(config: &R) -> Result<Self, RetryConfigError>
283    where
284        R: ConfigReader + ?Sized,
285    {
286        let default = Self::default();
287        let values = RetryConfigValues::new(config).map_err(RetryConfigError::from)?;
288        values.to_options(&default)
289    }
290
291    /// Validates all options.
292    ///
293    /// # Returns
294    /// `Ok(())` when all contained strategy parameters are usable.
295    ///
296    /// # Parameters
297    /// This method has no parameters.
298    ///
299    /// # Errors
300    /// Returns [`RetryConfigError`] with the relevant config key when the delay
301    /// or jitter strategy is invalid.
302    pub fn validate(&self) -> Result<(), RetryConfigError> {
303        self.delay
304            .validate()
305            .map_err(|message| RetryConfigError::invalid_value(KEY_DELAY, message))?;
306        self.jitter
307            .validate()
308            .map_err(|message| RetryConfigError::invalid_value(KEY_JITTER_FACTOR, message))?;
309        if let Some(attempt_timeout) = self.attempt_timeout {
310            attempt_timeout.validate().map_err(|message| {
311                RetryConfigError::invalid_value(KEY_ATTEMPT_TIMEOUT_MILLIS, message)
312            })?;
313        }
314        Ok(())
315    }
316
317    /// Calculates the base retry delay for one failed-attempt index.
318    ///
319    /// # Parameters
320    /// - `attempt`: Failed-attempt index, starting at 1.
321    ///
322    /// # Returns
323    /// Base delay before jitter.
324    pub fn base_delay_for_attempt(&self, attempt: u32) -> Duration {
325        self.delay.base_delay(attempt)
326    }
327
328    /// Calculates the retry delay for one failed-attempt index after jitter.
329    ///
330    /// # Parameters
331    /// - `attempt`: Failed-attempt index, starting at 1.
332    ///
333    /// # Returns
334    /// Delay after jitter is applied.
335    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
336        self.jitter.delay_for_attempt(&self.delay, attempt)
337    }
338
339    /// Calculates the next base delay from the current base delay.
340    ///
341    /// For exponential delay, this advances by one multiplier step from
342    /// `current` and caps at `max`; `Duration::ZERO` represents no previous
343    /// base delay and returns the exponential initial delay. For other
344    /// strategies, this delegates to the strategy's per-attempt base behavior.
345    ///
346    /// # Parameters
347    /// - `current`: Current base delay before jitter.
348    ///
349    /// # Returns
350    /// Next base delay before jitter.
351    pub fn next_base_delay_from_current(&self, current: Duration) -> Duration {
352        match &self.delay {
353            RetryDelay::None => Duration::ZERO,
354            RetryDelay::Fixed(delay) => *delay,
355            RetryDelay::Random { .. } => self.delay.base_delay(1),
356            RetryDelay::Exponential {
357                initial,
358                max,
359                multiplier,
360            } => {
361                if current.is_zero() {
362                    return *initial;
363                }
364                let bounded_current = current.min(*max);
365                let next = bounded_current.mul_f64(*multiplier);
366                if next > *max { *max } else { next }
367            }
368        }
369    }
370
371    /// Applies configured jitter to `base_delay`.
372    ///
373    /// # Parameters
374    /// - `base_delay`: Base delay before jitter.
375    ///
376    /// # Returns
377    /// Delay after jitter.
378    pub fn jittered_delay(&self, base_delay: Duration) -> Duration {
379        self.jitter.apply(base_delay)
380    }
381
382    /// Calculates the next delay from the current base delay and applies jitter.
383    ///
384    /// # Parameters
385    /// - `current`: Current base delay before jitter.
386    ///
387    /// # Returns
388    /// Next delay after jitter.
389    pub fn next_delay_from_current(&self, current: Duration) -> Duration {
390        self.jittered_delay(self.next_base_delay_from_current(current))
391    }
392
393    /// Returns the configured attempt-timeout duration.
394    ///
395    /// # Returns
396    /// `Some(Duration)` when per-attempt timeout is configured.
397    #[inline]
398    pub(crate) fn attempt_timeout_duration(&self) -> Option<Duration> {
399        self.attempt_timeout
400            .map(|attempt_timeout| attempt_timeout.timeout())
401    }
402
403    /// Returns the effective timeout used by the next attempt.
404    ///
405    /// # Parameters
406    /// - `operation_elapsed`: Cumulative user operation time consumed so far.
407    /// - `total_elapsed`: Total monotonic retry-flow time consumed so far.
408    ///
409    /// # Returns
410    /// The shortest of the configured attempt timeout, remaining
411    /// max-operation-elapsed budget, and remaining max-total-elapsed budget,
412    /// including the source that selected it. A configured timeout wins ties so
413    /// its timeout policy remains observable.
414    pub(crate) fn effective_attempt_timeout(
415        &self,
416        operation_elapsed: Duration,
417        total_elapsed: Duration,
418    ) -> EffectiveAttemptTimeout {
419        let candidates = [
420            self.attempt_timeout_duration()
421                .map(|duration| (duration, AttemptTimeoutSource::Configured)),
422            self.remaining_operation_elapsed(operation_elapsed)
423                .map(|duration| (duration, AttemptTimeoutSource::MaxOperationElapsed)),
424            self.remaining_total_elapsed(total_elapsed)
425                .map(|duration| (duration, AttemptTimeoutSource::MaxTotalElapsed)),
426        ];
427        let selected = candidates
428            .into_iter()
429            .flatten()
430            .min_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
431        match selected {
432            Some((duration, source)) => EffectiveAttemptTimeout::new(Some(duration), Some(source)),
433            None => EffectiveAttemptTimeout::none(),
434        }
435    }
436
437    /// Returns the first elapsed-budget reason that is exhausted.
438    ///
439    /// # Parameters
440    /// - `operation_elapsed`: Cumulative user operation time consumed by this flow.
441    /// - `total_elapsed`: Total monotonic retry-flow time consumed by this flow.
442    ///
443    /// # Returns
444    /// `Some(RetryErrorReason)` when an elapsed budget has been exhausted.
445    #[inline]
446    pub(crate) fn elapsed_error_reason(
447        &self,
448        operation_elapsed: Duration,
449        total_elapsed: Duration,
450    ) -> Option<RetryErrorReason> {
451        if self
452            .max_operation_elapsed
453            .is_some_and(|max_operation_elapsed| operation_elapsed >= max_operation_elapsed)
454        {
455            Some(RetryErrorReason::MaxOperationElapsedExceeded)
456        } else if self
457            .max_total_elapsed
458            .is_some_and(|max_total_elapsed| total_elapsed >= max_total_elapsed)
459        {
460            Some(RetryErrorReason::MaxTotalElapsedExceeded)
461        } else {
462            None
463        }
464    }
465
466    /// Returns whether a selected retry sleep would consume the remaining total budget.
467    ///
468    /// # Parameters
469    /// - `total_elapsed`: Total monotonic retry-flow time consumed before sleep.
470    /// - `delay`: Selected retry delay.
471    ///
472    /// # Returns
473    /// `true` when the delay should not be slept because no budget would remain
474    /// for the next attempt.
475    #[inline]
476    pub(crate) fn retry_sleep_exhausts_total_elapsed(
477        &self,
478        total_elapsed: Duration,
479        delay: Duration,
480    ) -> bool {
481        if delay.is_zero() {
482            return false;
483        }
484        let Some(max_total_elapsed) = self.max_total_elapsed else {
485            return false;
486        };
487        total_elapsed.saturating_add(delay) >= max_total_elapsed
488    }
489
490    /// Selects the delay used before the next retry.
491    ///
492    /// # Parameters
493    /// - `decision`: Failure decision.
494    /// - `attempts`: Attempts executed so far.
495    /// - `hint`: Optional retry-after hint.
496    ///
497    /// # Returns
498    /// Delay before the next retry.
499    pub(crate) fn retry_delay(
500        &self,
501        decision: AttemptFailureDecision,
502        attempts: u32,
503        hint: Option<Duration>,
504    ) -> Duration {
505        match decision {
506            AttemptFailureDecision::RetryAfter(delay) => delay,
507            AttemptFailureDecision::UseDefault => {
508                hint.unwrap_or_else(|| self.jitter.delay_for_attempt(&self.delay, attempts))
509            }
510            AttemptFailureDecision::Retry | AttemptFailureDecision::Abort => {
511                self.jitter.delay_for_attempt(&self.delay, attempts)
512            }
513        }
514    }
515
516    /// Returns remaining user operation time before the max-operation-elapsed budget is exhausted.
517    ///
518    /// # Parameters
519    /// - `operation_elapsed`: Cumulative user operation time consumed so far.
520    ///
521    /// # Returns
522    /// `Some(Duration)` when max elapsed is configured, or `None` when unlimited.
523    #[inline]
524    fn remaining_operation_elapsed(&self, operation_elapsed: Duration) -> Option<Duration> {
525        self.max_operation_elapsed
526            .map(|max_operation_elapsed| max_operation_elapsed.saturating_sub(operation_elapsed))
527    }
528
529    /// Returns remaining total retry-flow time before the max-total-elapsed budget is exhausted.
530    ///
531    /// # Parameters
532    /// - `total_elapsed`: Total monotonic retry-flow time consumed so far.
533    ///
534    /// # Returns
535    /// `Some(Duration)` when max total elapsed is configured, or `None` when unlimited.
536    #[inline]
537    fn remaining_total_elapsed(&self, total_elapsed: Duration) -> Option<Duration> {
538        self.max_total_elapsed
539            .map(|max_total_elapsed| max_total_elapsed.saturating_sub(total_elapsed))
540    }
541}
542
543impl Default for RetryOptions {
544    /// Creates the default retry options.
545    ///
546    /// # Returns
547    /// Options with five attempts, no cumulative user operation time limit,
548    /// exponential delay, no jitter, and the default worker cancellation grace.
549    ///
550    /// # Parameters
551    /// This function has no parameters.
552    ///
553    /// # Errors
554    /// This function does not return errors.
555    ///
556    /// # Panics
557    /// This function does not panic because the hard-coded attempt count is
558    /// non-zero.
559    #[inline]
560    fn default() -> Self {
561        Self {
562            max_attempts: NonZeroU32::new(DEFAULT_RETRY_MAX_ATTEMPTS)
563                .expect("default retry attempts must be non-zero"),
564            max_operation_elapsed: DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
565            max_total_elapsed: DEFAULT_RETRY_MAX_TOTAL_ELAPSED,
566            delay: RetryDelay::default(),
567            jitter: RetryJitter::default(),
568            attempt_timeout: None,
569            worker_cancel_grace: Duration::from_millis(DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS),
570        }
571    }
572}