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