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//! Runtime runners should not interpret individual knobs directly when there is
15//! combined behavior. Methods such as [`RetryOptions::effective_attempt_timeout`]
16//! and [`RetryOptions::retry_delay`] encode the library's precedence rules in
17//! one place.
18
19use std::num::NonZeroU32;
20use std::time::Duration;
21
22#[cfg(feature = "config")]
23use qubit_config::ConfigReader;
24
25#[cfg(feature = "config")]
26use super::retry_config_values::RetryConfigValues;
27use super::{
28    AttemptTimeoutOption,
29    EffectiveAttemptTimeout,
30};
31
32use crate::constants::{
33    DEFAULT_RETRY_MAX_ATTEMPTS,
34    DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
35    DEFAULT_RETRY_MAX_TOTAL_ELAPSED,
36    DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS,
37    KEY_ATTEMPT_TIMEOUT_MILLIS,
38    KEY_DELAY,
39    KEY_JITTER_FACTOR,
40    KEY_MAX_ATTEMPTS,
41};
42use crate::{
43    AttemptFailureDecision,
44    AttemptTimeoutSource,
45    RetryConfigError,
46    RetryDelay,
47    RetryErrorReason,
48    RetryJitter,
49};
50
51/// Immutable retry option snapshot used by [`crate::Retry`].
52///
53/// `RetryOptions` owns all executor configuration that is independent of the
54/// application error type: attempt limits, elapsed budgets, delay strategy, and
55/// jitter strategy. Construction validates the delay and jitter values before
56/// an executor can use them.
57///
58#[derive(Debug, Clone, PartialEq)]
59pub struct RetryOptions {
60    /// Maximum attempts, including the initial attempt.
61    pub(crate) max_attempts: NonZeroU32,
62    /// Maximum cumulative user operation time for the retry flow.
63    pub(crate) max_operation_elapsed: Option<Duration>,
64    /// Maximum monotonic elapsed time for the whole retry flow.
65    pub(crate) max_total_elapsed: Option<Duration>,
66    /// Base delay strategy between attempts.
67    pub(crate) delay: RetryDelay,
68    /// RetryJitter applied to each base delay.
69    pub(crate) jitter: RetryJitter,
70    /// Optional per-attempt timeout settings.
71    pub(crate) attempt_timeout: Option<AttemptTimeoutOption>,
72    /// Grace period for a timed-out worker to observe cancellation and exit.
73    pub(crate) worker_cancel_grace: Duration,
74}
75
76impl RetryOptions {
77    /// Returns maximum attempts, including the initial attempt.
78    ///
79    /// # Parameters
80    /// This method has no parameters.
81    ///
82    /// # Returns
83    /// Maximum attempts configured for one retry execution.
84    ///
85    /// # Errors
86    /// This method does not return errors.
87    #[inline]
88    pub fn max_attempts(&self) -> u32 {
89        self.max_attempts.get()
90    }
91
92    /// Returns maximum cumulative user operation time budget.
93    ///
94    /// # Parameters
95    /// This method has no parameters.
96    ///
97    /// # Returns
98    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
99    ///
100    /// # Errors
101    /// This method does not return errors.
102    #[inline]
103    pub fn max_operation_elapsed(&self) -> Option<Duration> {
104        self.max_operation_elapsed
105    }
106
107    /// Returns maximum total retry-flow elapsed time budget.
108    ///
109    /// This budget is measured with monotonic time and includes operation
110    /// execution, retry sleeps, retry-after sleeps, and retry control-path
111    /// listener time.
112    ///
113    /// # Parameters
114    /// This method has no parameters.
115    ///
116    /// # Returns
117    /// `Some(Duration)` for bounded executions, or `None` for unlimited.
118    ///
119    /// # Errors
120    /// This method does not return errors.
121    #[inline]
122    pub fn max_total_elapsed(&self) -> Option<Duration> {
123        self.max_total_elapsed
124    }
125
126    /// Returns the base delay strategy.
127    ///
128    /// # Parameters
129    /// This method has no parameters.
130    ///
131    /// # Returns
132    /// Borrowed delay strategy used by the executor.
133    ///
134    /// # Errors
135    /// This method does not return errors.
136    #[inline]
137    pub fn delay(&self) -> &RetryDelay {
138        &self.delay
139    }
140
141    /// Returns the jitter strategy.
142    ///
143    /// # Parameters
144    /// This method has no parameters.
145    ///
146    /// # Returns
147    /// Jitter strategy used by the executor.
148    ///
149    /// # Errors
150    /// This method does not return errors.
151    #[inline]
152    pub fn jitter(&self) -> RetryJitter {
153        self.jitter
154    }
155
156    /// Returns the optional per-attempt timeout settings.
157    ///
158    /// # Parameters
159    /// This method has no parameters.
160    ///
161    /// # Returns
162    /// `Some(AttemptTimeoutOption)` when per-attempt timeout is configured.
163    ///
164    /// # Errors
165    /// This method does not return errors.
166    #[inline]
167    pub fn attempt_timeout(&self) -> Option<AttemptTimeoutOption> {
168        self.attempt_timeout
169    }
170
171    /// Returns the worker cancellation grace period.
172    ///
173    /// # Parameters
174    /// This method has no parameters.
175    ///
176    /// # Returns
177    /// Duration the worker-thread executor waits after requesting cooperative
178    /// cancellation for a timed-out worker attempt.
179    #[inline]
180    pub fn worker_cancel_grace(&self) -> Duration {
181        self.worker_cancel_grace
182    }
183
184    /// Creates and validates a retry option snapshot.
185    ///
186    /// # Parameters
187    /// - `max_attempts`: Maximum number of attempts, including the first call.
188    ///   Must be greater than zero.
189    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
190    ///   attempts. Listener execution and retry sleeps are excluded.
191    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
192    ///   whole retry flow. Operation execution, retry sleeps, retry-after
193    ///   sleeps, and retry control-path listener time are included.
194    /// - `delay`: Base delay strategy used between attempts.
195    /// - `jitter`: RetryJitter strategy applied to each base delay.
196    ///
197    /// # Returns
198    /// A validated [`RetryOptions`] value.
199    ///
200    /// # Errors
201    /// Returns [`RetryConfigError`] when `max_attempts` is zero, or when
202    /// `delay` or `jitter` contains invalid parameters.
203    pub fn new(
204        max_attempts: u32,
205        max_operation_elapsed: Option<Duration>,
206        max_total_elapsed: Option<Duration>,
207        delay: RetryDelay,
208        jitter: RetryJitter,
209    ) -> Result<Self, RetryConfigError> {
210        Self::new_with_attempt_timeout(
211            max_attempts,
212            max_operation_elapsed,
213            max_total_elapsed,
214            delay,
215            jitter,
216            None,
217        )
218    }
219
220    /// Creates and validates a retry option snapshot with attempt timeout.
221    ///
222    /// # Parameters
223    /// - `max_attempts`: Maximum number of attempts, including the first call.
224    ///   Must be greater than zero.
225    /// - `max_operation_elapsed`: Optional cumulative user operation time budget for all
226    ///   attempts. Listener execution and retry sleeps are excluded.
227    /// - `max_total_elapsed`: Optional monotonic elapsed-time budget for the
228    ///   whole retry flow. Operation execution, retry sleeps, retry-after
229    ///   sleeps, and retry control-path listener time are included.
230    /// - `delay`: Base delay strategy used between attempts.
231    /// - `jitter`: RetryJitter strategy applied to each base delay.
232    /// - `attempt_timeout`: Optional per-attempt timeout settings.
233    ///
234    /// # Returns
235    /// A validated [`RetryOptions`] value.
236    ///
237    /// # Errors
238    /// Returns [`RetryConfigError`] when `max_attempts` is zero, when delay or
239    /// jitter contains invalid parameters, or when the attempt timeout is zero.
240    pub fn new_with_attempt_timeout(
241        max_attempts: u32,
242        max_operation_elapsed: Option<Duration>,
243        max_total_elapsed: Option<Duration>,
244        delay: RetryDelay,
245        jitter: RetryJitter,
246        attempt_timeout: Option<AttemptTimeoutOption>,
247    ) -> Result<Self, RetryConfigError> {
248        let max_attempts = NonZeroU32::new(max_attempts).ok_or_else(|| {
249            RetryConfigError::invalid_value(KEY_MAX_ATTEMPTS, "max_attempts must be greater than zero")
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
311                .validate()
312                .map_err(|message| RetryConfigError::invalid_value(KEY_ATTEMPT_TIMEOUT_MILLIS, message))?;
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.map(|attempt_timeout| attempt_timeout.timeout())
400    }
401
402    /// Returns the effective timeout used by the next attempt.
403    ///
404    /// # Parameters
405    /// - `operation_elapsed`: Cumulative user operation time consumed so far.
406    /// - `total_elapsed`: Total monotonic retry-flow time consumed so far.
407    ///
408    /// # Returns
409    /// The shortest of the configured attempt timeout, remaining
410    /// max-operation-elapsed budget, and remaining max-total-elapsed budget,
411    /// including the source that selected it. A configured timeout wins ties so
412    /// its timeout policy remains observable.
413    pub(crate) fn effective_attempt_timeout(
414        &self,
415        operation_elapsed: Duration,
416        total_elapsed: Duration,
417    ) -> EffectiveAttemptTimeout {
418        // Build all timeout candidates with their source, then pick the
419        // shortest remaining duration. Keeping the source is important: a
420        // timeout caused by an elapsed budget is terminal, while a configured
421        // attempt timeout still follows AttemptTimeoutPolicy.
422        let candidates = [
423            self.attempt_timeout_duration()
424                .map(|duration| (duration, AttemptTimeoutSource::Configured)),
425            self.remaining_operation_elapsed(operation_elapsed)
426                .map(|duration| (duration, AttemptTimeoutSource::MaxOperationElapsed)),
427            self.remaining_total_elapsed(total_elapsed)
428                .map(|duration| (duration, AttemptTimeoutSource::MaxTotalElapsed)),
429        ];
430        let selected = candidates
431            .into_iter()
432            .flatten()
433            .min_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
434        // AttemptTimeoutSource derives Ord in the precedence we need for ties:
435        // Configured wins over elapsed-budget candidates so a configured
436        // timeout exactly equal to the remaining budget remains observable as a
437        // configured attempt timeout.
438        match selected {
439            Some((duration, source)) => EffectiveAttemptTimeout::new(Some(duration), Some(source)),
440            None => EffectiveAttemptTimeout::none(),
441        }
442    }
443
444    /// Returns the first elapsed-budget reason that is exhausted.
445    ///
446    /// # Parameters
447    /// - `operation_elapsed`: Cumulative user operation time consumed by this flow.
448    /// - `total_elapsed`: Total monotonic retry-flow time consumed by this flow.
449    ///
450    /// # Returns
451    /// `Some(RetryErrorReason)` when an elapsed budget has been exhausted.
452    #[inline]
453    pub(crate) fn elapsed_error_reason(
454        &self,
455        operation_elapsed: Duration,
456        total_elapsed: Duration,
457    ) -> Option<RetryErrorReason> {
458        if self
459            .max_operation_elapsed
460            .is_some_and(|max_operation_elapsed| operation_elapsed >= max_operation_elapsed)
461        {
462            Some(RetryErrorReason::MaxOperationElapsedExceeded)
463        } else if self
464            .max_total_elapsed
465            .is_some_and(|max_total_elapsed| total_elapsed >= max_total_elapsed)
466        {
467            Some(RetryErrorReason::MaxTotalElapsedExceeded)
468        } else {
469            None
470        }
471    }
472
473    /// Returns whether a selected retry sleep would consume the remaining total budget.
474    ///
475    /// # Parameters
476    /// - `total_elapsed`: Total monotonic retry-flow time consumed before sleep.
477    /// - `delay`: Selected retry delay.
478    ///
479    /// # Returns
480    /// `true` when the delay should not be slept because no budget would remain
481    /// for the next attempt.
482    #[inline]
483    pub(crate) fn retry_sleep_exhausts_total_elapsed(&self, total_elapsed: Duration, delay: Duration) -> bool {
484        if delay.is_zero() {
485            return false;
486        }
487        let Some(max_total_elapsed) = self.max_total_elapsed else {
488            return false;
489        };
490        total_elapsed.saturating_add(delay) >= max_total_elapsed
491    }
492
493    /// Selects the delay used before the next retry.
494    ///
495    /// # Parameters
496    /// - `decision`: Failure decision.
497    /// - `attempts`: Attempts executed so far.
498    /// - `hint`: Optional retry-after hint.
499    ///
500    /// # Returns
501    /// Delay before the next retry.
502    pub(crate) fn retry_delay(
503        &self,
504        decision: AttemptFailureDecision,
505        attempts: u32,
506        hint: Option<Duration>,
507    ) -> Duration {
508        // Delay precedence is part of the public retry contract:
509        // RetryAfter is an explicit listener override, retry-after hints apply
510        // only when listeners left the default policy in charge, and Retry uses
511        // the configured strategy directly.
512        match decision {
513            AttemptFailureDecision::RetryAfter(delay) => delay,
514            AttemptFailureDecision::UseDefault => {
515                hint.unwrap_or_else(|| self.jitter.delay_for_attempt(&self.delay, attempts))
516            }
517            AttemptFailureDecision::Retry | AttemptFailureDecision::Abort => {
518                self.jitter.delay_for_attempt(&self.delay, attempts)
519            }
520        }
521    }
522
523    /// Returns remaining user operation time before the max-operation-elapsed budget is exhausted.
524    ///
525    /// # Parameters
526    /// - `operation_elapsed`: Cumulative user operation time consumed so far.
527    ///
528    /// # Returns
529    /// `Some(Duration)` when max elapsed is configured, or `None` when unlimited.
530    #[inline]
531    fn remaining_operation_elapsed(&self, operation_elapsed: Duration) -> Option<Duration> {
532        self.max_operation_elapsed
533            .map(|max_operation_elapsed| max_operation_elapsed.saturating_sub(operation_elapsed))
534    }
535
536    /// Returns remaining total retry-flow time before the max-total-elapsed budget is exhausted.
537    ///
538    /// # Parameters
539    /// - `total_elapsed`: Total monotonic retry-flow time consumed so far.
540    ///
541    /// # Returns
542    /// `Some(Duration)` when max total elapsed is configured, or `None` when unlimited.
543    #[inline]
544    fn remaining_total_elapsed(&self, total_elapsed: Duration) -> Option<Duration> {
545        self.max_total_elapsed
546            .map(|max_total_elapsed| max_total_elapsed.saturating_sub(total_elapsed))
547    }
548}
549
550impl Default for RetryOptions {
551    /// Creates the default retry options.
552    ///
553    /// # Returns
554    /// Options with five attempts, no cumulative user operation time limit,
555    /// exponential delay, no jitter, and the default worker cancellation grace.
556    ///
557    /// # Parameters
558    /// This function has no parameters.
559    ///
560    /// # Errors
561    /// This function does not return errors.
562    ///
563    /// # Panics
564    /// This function does not panic because the hard-coded attempt count is
565    /// non-zero.
566    #[inline]
567    fn default() -> Self {
568        Self {
569            max_attempts: NonZeroU32::new(DEFAULT_RETRY_MAX_ATTEMPTS).expect("default retry attempts must be non-zero"),
570            max_operation_elapsed: DEFAULT_RETRY_MAX_OPERATION_ELAPSED,
571            max_total_elapsed: DEFAULT_RETRY_MAX_TOTAL_ELAPSED,
572            delay: RetryDelay::default(),
573            jitter: RetryJitter::default(),
574            attempt_timeout: None,
575            worker_cancel_grace: Duration::from_millis(DEFAULT_RETRY_WORKER_CANCEL_GRACE_MILLIS),
576        }
577    }
578}