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::RetryExecutor`]
12//! and the private helpers that translate `qubit-config` values into strongly
13//! typed retry settings.
14
15use std::num::NonZeroU32;
16use std::time::Duration;
17
18use qubit_config::{ConfigReader, ConfigResult};
19
20use crate::{RetryConfigError, RetryDelay, RetryJitter};
21
22/// Immutable retry option snapshot used by [`crate::RetryExecutor`].
23///
24/// `RetryOptions` owns all executor configuration that is independent of the
25/// application error type: attempt limits, total elapsed-time budget, delay
26/// strategy, and jitter strategy. Construction validates the delay and jitter
27/// values before an executor can use them.
28#[derive(Debug, Clone, PartialEq)]
29pub struct RetryOptions {
30 /// Maximum attempts, including the initial attempt.
31 pub max_attempts: NonZeroU32,
32 /// Maximum total elapsed time for the retry flow, in milliseconds.
33 pub max_elapsed: Option<Duration>,
34 /// Base delay strategy between attempts.
35 pub delay: RetryDelay,
36 /// RetryJitter applied to each base delay.
37 pub jitter: RetryJitter,
38}
39
40impl RetryOptions {
41 /// Key for maximum attempts.
42 pub const KEY_MAX_ATTEMPTS: &'static str = "max_attempts";
43 /// Key for maximum elapsed budget in milliseconds. Missing means unlimited;
44 /// zero also maps to unlimited when read from config.
45 pub const KEY_MAX_ELAPSED_MILLIS: &'static str = "max_elapsed_millis";
46 /// Key for delay strategy name.
47 pub const KEY_DELAY: &'static str = "delay";
48 /// Backward-compatible alias for delay strategy name.
49 pub const KEY_DELAY_STRATEGY: &'static str = "delay_strategy";
50 /// Key for fixed delay in milliseconds.
51 pub const KEY_FIXED_DELAY_MILLIS: &'static str = "fixed_delay_millis";
52 /// Key for random minimum delay in milliseconds.
53 pub const KEY_RANDOM_MIN_DELAY_MILLIS: &'static str = "random_min_delay_millis";
54 /// Key for random maximum delay in milliseconds.
55 pub const KEY_RANDOM_MAX_DELAY_MILLIS: &'static str = "random_max_delay_millis";
56 /// Key for exponential initial delay in milliseconds.
57 pub const KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS: &'static str =
58 "exponential_initial_delay_millis";
59 /// Key for exponential maximum delay in milliseconds.
60 pub const KEY_EXPONENTIAL_MAX_DELAY_MILLIS: &'static str = "exponential_max_delay_millis";
61 /// Key for exponential multiplier.
62 pub const KEY_EXPONENTIAL_MULTIPLIER: &'static str = "exponential_multiplier";
63 /// Key for jitter factor.
64 pub const KEY_JITTER_FACTOR: &'static str = "jitter_factor";
65
66 /// Creates and validates a retry option snapshot.
67 ///
68 /// # Parameters
69 /// - `max_attempts`: Maximum number of attempts, including the first call.
70 /// Must be greater than zero.
71 /// - `max_elapsed`: Optional total elapsed-time budget for all attempts
72 /// and sleeps.
73 /// - `delay`: Base delay strategy used between attempts.
74 /// - `jitter`: RetryJitter strategy applied to each base delay.
75 ///
76 /// # Returns
77 /// A validated [`RetryOptions`] value.
78 ///
79 /// # Errors
80 /// Returns [`RetryConfigError`] when `max_attempts` is zero, or when
81 /// `delay` or `jitter` contains invalid parameters.
82 pub fn new(
83 max_attempts: u32,
84 max_elapsed: Option<Duration>,
85 delay: RetryDelay,
86 jitter: RetryJitter,
87 ) -> Result<Self, RetryConfigError> {
88 let max_attempts = NonZeroU32::new(max_attempts).ok_or_else(|| {
89 RetryConfigError::invalid_value(
90 Self::KEY_MAX_ATTEMPTS,
91 "max_attempts must be greater than zero",
92 )
93 })?;
94 let options = Self {
95 max_attempts,
96 max_elapsed,
97 delay,
98 jitter,
99 };
100 options.validate()?;
101 Ok(options)
102 }
103
104 /// Reads a retry option snapshot from a `ConfigReader`.
105 ///
106 /// Keys are relative to the reader. Use `config.prefix_view("retry")` when
107 /// the retry settings are nested under a `retry.` prefix.
108 ///
109 /// # Parameters
110 /// - `config`: Configuration reader whose keys are relative to the retry
111 /// configuration prefix.
112 ///
113 /// # Returns
114 /// A validated [`RetryOptions`] value. Missing keys fall back to
115 /// [`RetryOptions::default`].
116 ///
117 /// # Errors
118 /// Returns [`RetryConfigError`] when a key cannot be read as the expected
119 /// type, the delay strategy name is unsupported, or the resulting options
120 /// fail validation.
121 pub fn from_config<R>(config: &R) -> Result<Self, RetryConfigError>
122 where
123 R: ConfigReader + ?Sized,
124 {
125 let default = Self::default();
126 let values = RetryConfigValues::read_from(config).map_err(RetryConfigError::from)?;
127 values.to_options(&default)
128 }
129
130 /// Validates all options.
131 ///
132 /// # Returns
133 /// `Ok(())` when all contained strategy parameters are usable.
134 ///
135 /// # Parameters
136 /// This method has no parameters.
137 ///
138 /// # Errors
139 /// Returns [`RetryConfigError`] with the relevant config key when the delay
140 /// or jitter strategy is invalid.
141 pub fn validate(&self) -> Result<(), RetryConfigError> {
142 self.delay
143 .validate()
144 .map_err(|message| RetryConfigError::invalid_value(Self::KEY_DELAY, message))?;
145 self.jitter
146 .validate()
147 .map_err(|message| RetryConfigError::invalid_value(Self::KEY_JITTER_FACTOR, message))?;
148 Ok(())
149 }
150}
151
152impl Default for RetryOptions {
153 /// Creates the default retry options.
154 ///
155 /// # Returns
156 /// Options with three attempts, no total elapsed-time limit, exponential
157 /// delay, and no jitter.
158 ///
159 /// # Parameters
160 /// This function has no parameters.
161 ///
162 /// # Errors
163 /// This function does not return errors.
164 ///
165 /// # Panics
166 /// This function does not panic because the hard-coded attempt count is
167 /// non-zero.
168 #[inline]
169 fn default() -> Self {
170 Self {
171 max_attempts: NonZeroU32::new(3).expect("default retry attempts must be non-zero"),
172 max_elapsed: None,
173 delay: RetryDelay::default(),
174 jitter: RetryJitter::None,
175 }
176 }
177}
178
179/// Raw retry configuration values read from `qubit-config`.
180///
181/// This struct deliberately keeps all `ConfigReader` calls in one place. The
182/// conversion from `qubit-config` errors to retry-specific errors happens at
183/// the caller boundary, while the remaining methods only translate already
184/// typed values into retry domain objects.
185#[derive(Debug, Clone, PartialEq)]
186struct RetryConfigValues {
187 /// Optional maximum attempts value.
188 max_attempts: Option<u32>,
189 /// Optional elapsed-time budget in milliseconds.
190 max_elapsed_millis: Option<u64>,
191 /// Optional primary delay strategy name.
192 delay: Option<String>,
193 /// Optional backward-compatible delay strategy alias.
194 delay_strategy: Option<String>,
195 /// Optional fixed delay in milliseconds.
196 fixed_delay_millis: Option<u64>,
197 /// Optional random delay lower bound in milliseconds.
198 random_min_delay_millis: Option<u64>,
199 /// Optional random delay upper bound in milliseconds.
200 random_max_delay_millis: Option<u64>,
201 /// Optional exponential initial delay in milliseconds.
202 exponential_initial_delay_millis: Option<u64>,
203 /// Optional exponential maximum delay in milliseconds.
204 exponential_max_delay_millis: Option<u64>,
205 /// Optional exponential multiplier.
206 exponential_multiplier: Option<f64>,
207 /// Optional jitter factor.
208 jitter_factor: Option<f64>,
209}
210
211impl RetryConfigValues {
212 /// Reads all retry-related configuration values.
213 ///
214 /// # Parameters
215 /// - `config`: Configuration reader whose keys are relative to the retry
216 /// configuration prefix.
217 ///
218 /// # Returns
219 /// A [`RetryConfigValues`] snapshot containing every retry option key
220 /// understood by this crate.
221 ///
222 /// # Errors
223 /// Returns `qubit-config`'s `ConfigError` through [`ConfigResult`] when any
224 /// present key cannot be read as the expected type or string substitution
225 /// fails.
226 fn read_from<R>(config: &R) -> ConfigResult<Self>
227 where
228 R: ConfigReader + ?Sized,
229 {
230 Ok(Self {
231 max_attempts: config.get_optional(RetryOptions::KEY_MAX_ATTEMPTS)?,
232 max_elapsed_millis: config.get_optional(RetryOptions::KEY_MAX_ELAPSED_MILLIS)?,
233 delay: config.get_optional_string(RetryOptions::KEY_DELAY)?,
234 delay_strategy: config.get_optional_string(RetryOptions::KEY_DELAY_STRATEGY)?,
235 fixed_delay_millis: config.get_optional(RetryOptions::KEY_FIXED_DELAY_MILLIS)?,
236 random_min_delay_millis: config
237 .get_optional(RetryOptions::KEY_RANDOM_MIN_DELAY_MILLIS)?,
238 random_max_delay_millis: config
239 .get_optional(RetryOptions::KEY_RANDOM_MAX_DELAY_MILLIS)?,
240 exponential_initial_delay_millis: config
241 .get_optional(RetryOptions::KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS)?,
242 exponential_max_delay_millis: config
243 .get_optional(RetryOptions::KEY_EXPONENTIAL_MAX_DELAY_MILLIS)?,
244 exponential_multiplier: config
245 .get_optional(RetryOptions::KEY_EXPONENTIAL_MULTIPLIER)?,
246 jitter_factor: config.get_optional(RetryOptions::KEY_JITTER_FACTOR)?,
247 })
248 }
249
250 /// Converts the raw configuration snapshot into validated retry options.
251 ///
252 /// # Parameters
253 /// - `default`: Default options used when a config key is absent.
254 ///
255 /// # Returns
256 /// A validated [`RetryOptions`] value.
257 ///
258 /// # Errors
259 /// Returns [`RetryConfigError`] when the delay strategy name is unsupported
260 /// or the resulting options fail validation.
261 fn to_options(&self, default: &RetryOptions) -> Result<RetryOptions, RetryConfigError> {
262 let max_attempts = self.max_attempts.unwrap_or(default.max_attempts.get());
263 let max_elapsed = self.max_elapsed();
264 let delay = self.delay(default)?;
265 let jitter = self.jitter(default);
266 RetryOptions::new(max_attempts, max_elapsed, delay, jitter)
267 }
268
269 /// Resolves the elapsed-time budget.
270 ///
271 /// # Parameters
272 /// This method has no parameters.
273 ///
274 /// # Returns
275 /// `Some(Duration)` when `max_elapsed_millis` is present and non-zero;
276 /// otherwise `None`.
277 ///
278 /// # Errors
279 /// This method does not return errors.
280 fn max_elapsed(&self) -> Option<Duration> {
281 match self.max_elapsed_millis {
282 Some(0) | None => None,
283 Some(millis) => Some(Duration::from_millis(millis)),
284 }
285 }
286
287 /// Resolves the base delay strategy.
288 ///
289 /// # Parameters
290 /// - `default`: Default options used when neither explicit nor implicit
291 /// delay configuration is present.
292 ///
293 /// # Returns
294 /// The explicit, implicit, or default [`RetryDelay`] strategy.
295 ///
296 /// # Errors
297 /// Returns [`RetryConfigError`] when the explicit delay strategy name is
298 /// unsupported.
299 fn delay(&self, default: &RetryOptions) -> Result<RetryDelay, RetryConfigError> {
300 let strategy = self
301 .delay
302 .as_deref()
303 .or(self.delay_strategy.as_deref())
304 .map(str::trim)
305 .map(|value| value.to_ascii_lowercase());
306 match strategy.as_deref() {
307 None => Ok(self
308 .implicit_delay()
309 .unwrap_or_else(|| default.delay.clone())),
310 Some("none") => Ok(RetryDelay::None),
311 Some("fixed") => Ok(RetryDelay::fixed(Duration::from_millis(
312 self.fixed_delay_millis.unwrap_or(1000),
313 ))),
314 Some("random") => Ok(RetryDelay::random(
315 Duration::from_millis(self.random_min_delay_millis.unwrap_or(1000)),
316 Duration::from_millis(self.random_max_delay_millis.unwrap_or(10000)),
317 )),
318 Some("exponential") | Some("exponential_backoff") => Ok(RetryDelay::exponential(
319 Duration::from_millis(self.exponential_initial_delay_millis.unwrap_or(1000)),
320 Duration::from_millis(self.exponential_max_delay_millis.unwrap_or(60000)),
321 self.exponential_multiplier.unwrap_or(2.0),
322 )),
323 Some(other) => Err(RetryConfigError::invalid_value(
324 RetryOptions::KEY_DELAY,
325 format!("unsupported delay strategy '{other}'"),
326 )),
327 }
328 }
329
330 /// Resolves a delay strategy from parameter keys when no strategy name is configured.
331 ///
332 /// # Parameters
333 /// This method has no parameters.
334 ///
335 /// # Returns
336 /// `Some(RetryDelay)` when any delay parameter key is present; otherwise `None`.
337 ///
338 /// # Errors
339 /// This method does not return errors because all config reads have already
340 /// succeeded.
341 fn implicit_delay(&self) -> Option<RetryDelay> {
342 if let Some(millis) = self.fixed_delay_millis {
343 return Some(RetryDelay::fixed(Duration::from_millis(millis)));
344 }
345 if self.random_min_delay_millis.is_some() || self.random_max_delay_millis.is_some() {
346 return Some(RetryDelay::random(
347 Duration::from_millis(self.random_min_delay_millis.unwrap_or(1000)),
348 Duration::from_millis(self.random_max_delay_millis.unwrap_or(10000)),
349 ));
350 }
351 if self.exponential_initial_delay_millis.is_some()
352 || self.exponential_max_delay_millis.is_some()
353 || self.exponential_multiplier.is_some()
354 {
355 return Some(RetryDelay::exponential(
356 Duration::from_millis(self.exponential_initial_delay_millis.unwrap_or(1000)),
357 Duration::from_millis(self.exponential_max_delay_millis.unwrap_or(60000)),
358 self.exponential_multiplier.unwrap_or(2.0),
359 ));
360 }
361 None
362 }
363
364 /// Resolves the jitter strategy.
365 ///
366 /// # Parameters
367 /// - `default`: Default options used when no jitter key is present or the
368 /// configured jitter factor is `0.0`.
369 ///
370 /// # Returns
371 /// The configured or default [`RetryJitter`] strategy.
372 ///
373 /// # Errors
374 /// This method does not return errors. RetryJitter value validation is handled
375 /// by [`RetryOptions::new`].
376 fn jitter(&self, default: &RetryOptions) -> RetryJitter {
377 match self.jitter_factor {
378 Some(0.0) | None => default.jitter,
379 Some(factor) => RetryJitter::Factor(factor),
380 }
381 }
382}