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}