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}