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