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