Skip to main content

qubit_retry/options/
retry_config_values.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//! Raw retry configuration values from `qubit-config` and merge into
11//! [`RetryOptions`](crate::options::RetryOptions).
12//!
13
14use std::str::FromStr;
15use std::time::Duration;
16
17use qubit_config::{
18    ConfigReader,
19    ConfigResult,
20};
21
22use super::attempt_timeout_option::AttemptTimeoutOption;
23use super::attempt_timeout_policy::AttemptTimeoutPolicy;
24use super::retry_delay::RetryDelay;
25use super::retry_jitter::RetryJitter;
26use super::retry_options::RetryOptions;
27
28use crate::RetryConfigError;
29use crate::constants::{
30    DEFAULT_RETRY_EXPONENTIAL_INITIAL_DELAY_MILLIS,
31    DEFAULT_RETRY_EXPONENTIAL_MAX_DELAY_MILLIS,
32    DEFAULT_RETRY_EXPONENTIAL_MULTIPLIER,
33    DEFAULT_RETRY_JITTER_FACTOR,
34    DEFAULT_RETRY_RANDOM_MAX_DELAY_MILLIS,
35    DEFAULT_RETRY_RANDOM_MIN_DELAY_MILLIS,
36    KEY_ATTEMPT_TIMEOUT_MILLIS,
37    KEY_ATTEMPT_TIMEOUT_POLICY,
38    KEY_DELAY,
39    KEY_DELAY_STRATEGY,
40    KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS,
41    KEY_EXPONENTIAL_MAX_DELAY_MILLIS,
42    KEY_EXPONENTIAL_MULTIPLIER,
43    KEY_FIXED_DELAY_MILLIS,
44    KEY_JITTER_FACTOR,
45    KEY_MAX_ATTEMPTS,
46    KEY_MAX_OPERATION_ELAPSED_MILLIS,
47    KEY_MAX_OPERATION_ELAPSED_UNLIMITED,
48    KEY_MAX_TOTAL_ELAPSED_MILLIS,
49    KEY_MAX_TOTAL_ELAPSED_UNLIMITED,
50    KEY_RANDOM_MAX_DELAY_MILLIS,
51    KEY_RANDOM_MIN_DELAY_MILLIS,
52    KEY_WORKER_CANCEL_GRACE_MILLIS,
53};
54
55/// Raw retry configuration values read from `qubit-config`.
56///
57/// This struct deliberately keeps all `ConfigReader` calls in one place. The
58/// conversion from `qubit-config` errors to retry-specific errors happens at
59/// the caller boundary, while the remaining methods only translate already
60/// typed values into retry domain objects.
61///
62/// Fields are public so callers and integration tests can build snapshots
63/// programmatically and merge them with [`RetryConfigValues::to_options`].
64///
65#[derive(Debug, Clone, PartialEq)]
66pub struct RetryConfigValues {
67    /// Optional maximum attempts value.
68    pub max_attempts: Option<u32>,
69    /// Optional cumulative user operation elapsed-time budget in milliseconds.
70    pub max_operation_elapsed_millis: Option<u64>,
71    /// Optional explicit switch for unlimited user operation elapsed-time budget.
72    pub max_operation_elapsed_unlimited: Option<bool>,
73    /// Optional total retry-flow elapsed-time budget in milliseconds.
74    pub max_total_elapsed_millis: Option<u64>,
75    /// Optional explicit switch for unlimited total retry-flow elapsed-time budget.
76    pub max_total_elapsed_unlimited: Option<bool>,
77    /// Optional attempt timeout in milliseconds.
78    pub attempt_timeout_millis: Option<u64>,
79    /// Optional action selected when one attempt times out.
80    pub attempt_timeout_policy: Option<String>,
81    /// Optional worker cancellation grace period in milliseconds.
82    pub worker_cancel_grace_millis: Option<u64>,
83    /// Optional primary delay strategy name.
84    pub delay: Option<String>,
85    /// Optional backward-compatible delay strategy alias.
86    pub delay_strategy: Option<String>,
87    /// Optional fixed delay in milliseconds.
88    pub fixed_delay_millis: Option<u64>,
89    /// Optional random delay lower bound in milliseconds.
90    pub random_min_delay_millis: Option<u64>,
91    /// Optional random delay upper bound in milliseconds.
92    pub random_max_delay_millis: Option<u64>,
93    /// Optional exponential initial delay in milliseconds.
94    pub exponential_initial_delay_millis: Option<u64>,
95    /// Optional exponential maximum delay in milliseconds.
96    pub exponential_max_delay_millis: Option<u64>,
97    /// Optional exponential multiplier.
98    pub exponential_multiplier: Option<f64>,
99    /// Optional jitter factor.
100    pub jitter_factor: Option<f64>,
101}
102
103impl RetryConfigValues {
104    /// Creates a snapshot by reading all retry-related configuration values.
105    ///
106    /// # Parameters
107    /// - `config`: Configuration reader whose keys are relative to the retry
108    ///   configuration prefix.
109    ///
110    /// # Returns
111    /// A [`RetryConfigValues`] snapshot containing every retry option key
112    /// understood by this crate.
113    ///
114    /// # Errors
115    /// Returns `qubit-config`'s `ConfigError` through [`ConfigResult`] when any
116    /// present key cannot be read as the expected type or string substitution
117    /// fails.
118    pub(crate) fn new<R>(config: &R) -> ConfigResult<Self>
119    where
120        R: ConfigReader + ?Sized,
121    {
122        Ok(Self {
123            max_attempts: config.get_optional(KEY_MAX_ATTEMPTS)?,
124            max_operation_elapsed_millis: config.get_optional(KEY_MAX_OPERATION_ELAPSED_MILLIS)?,
125            max_operation_elapsed_unlimited: config
126                .get_optional(KEY_MAX_OPERATION_ELAPSED_UNLIMITED)?,
127            max_total_elapsed_millis: config.get_optional(KEY_MAX_TOTAL_ELAPSED_MILLIS)?,
128            max_total_elapsed_unlimited: config.get_optional(KEY_MAX_TOTAL_ELAPSED_UNLIMITED)?,
129            attempt_timeout_millis: config.get_optional(KEY_ATTEMPT_TIMEOUT_MILLIS)?,
130            attempt_timeout_policy: config.get_optional_string(KEY_ATTEMPT_TIMEOUT_POLICY)?,
131            worker_cancel_grace_millis: config.get_optional(KEY_WORKER_CANCEL_GRACE_MILLIS)?,
132            delay: config.get_optional_string(KEY_DELAY)?,
133            delay_strategy: config.get_optional_string(KEY_DELAY_STRATEGY)?,
134            fixed_delay_millis: config.get_optional(KEY_FIXED_DELAY_MILLIS)?,
135            random_min_delay_millis: config.get_optional(KEY_RANDOM_MIN_DELAY_MILLIS)?,
136            random_max_delay_millis: config.get_optional(KEY_RANDOM_MAX_DELAY_MILLIS)?,
137            exponential_initial_delay_millis: config
138                .get_optional(KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS)?,
139            exponential_max_delay_millis: config.get_optional(KEY_EXPONENTIAL_MAX_DELAY_MILLIS)?,
140            exponential_multiplier: config.get_optional(KEY_EXPONENTIAL_MULTIPLIER)?,
141            jitter_factor: config.get_optional(KEY_JITTER_FACTOR)?,
142        })
143    }
144
145    /// Converts the raw configuration snapshot into validated retry options.
146    ///
147    /// # Parameters
148    /// - `default`: Default options used when a config key is absent.
149    ///
150    /// # Returns
151    /// A validated [`RetryOptions`] value.
152    ///
153    /// # Errors
154    /// Returns [`RetryConfigError`] when the delay strategy name is unsupported
155    /// or the resulting options fail validation.
156    pub fn to_options(&self, default: &RetryOptions) -> Result<RetryOptions, RetryConfigError> {
157        let max_attempts = self.max_attempts.unwrap_or(default.max_attempts());
158        let max_operation_elapsed = self.get_max_operation_elapsed(default);
159        let max_total_elapsed = self.get_max_total_elapsed(default);
160        let attempt_timeout = self.get_attempt_timeout(default)?;
161        let worker_cancel_grace = self.get_worker_cancel_grace(default);
162        let delay = self.get_delay(default)?;
163        let jitter = self.get_jitter(default);
164        let mut options = RetryOptions::new_with_attempt_timeout(
165            max_attempts,
166            max_operation_elapsed,
167            max_total_elapsed,
168            delay,
169            jitter,
170            attempt_timeout,
171        )?;
172        options.worker_cancel_grace = worker_cancel_grace;
173        options.validate()?;
174        Ok(options)
175    }
176
177    /// Resolves the cumulative user operation elapsed-time budget.
178    ///
179    /// # Parameters
180    /// - `default`: Fallback when `max_operation_elapsed_millis` is absent from config.
181    ///
182    /// # Returns
183    /// - `None` when `max_operation_elapsed_unlimited` is configured as `true`.
184    /// - `Some(Duration)` when `max_operation_elapsed_millis` is present (including zero).
185    /// - `default.max_operation_elapsed` when the key is absent.
186    ///
187    /// # Errors
188    /// This method does not return errors.
189    fn get_max_operation_elapsed(&self, default: &RetryOptions) -> Option<Duration> {
190        if self.max_operation_elapsed_unlimited.unwrap_or(false) {
191            return None;
192        }
193        match self.max_operation_elapsed_millis {
194            Some(millis) => Some(Duration::from_millis(millis)),
195            None => default.max_operation_elapsed(),
196        }
197    }
198
199    /// Resolves the total retry-flow elapsed-time budget.
200    ///
201    /// # Parameters
202    /// - `default`: Fallback when `max_total_elapsed_millis` is absent from config.
203    ///
204    /// # Returns
205    /// - `None` when `max_total_elapsed_unlimited` is configured as `true`.
206    /// - `Some(Duration)` when `max_total_elapsed_millis` is present (including zero).
207    /// - `default.max_total_elapsed` when the key is absent.
208    ///
209    /// # Errors
210    /// This method does not return errors.
211    fn get_max_total_elapsed(&self, default: &RetryOptions) -> Option<Duration> {
212        if self.max_total_elapsed_unlimited.unwrap_or(false) {
213            return None;
214        }
215        match self.max_total_elapsed_millis {
216            Some(millis) => Some(Duration::from_millis(millis)),
217            None => default.max_total_elapsed(),
218        }
219    }
220
221    /// Resolves per-attempt timeout settings.
222    ///
223    /// # Parameters
224    /// - `default`: Default options used when timeout keys are absent.
225    ///
226    /// # Returns
227    /// `Ok(Some(AttemptTimeoutOption))` when a timeout is configured, or
228    /// `Ok(None)` when per-attempt timeout is disabled.
229    ///
230    /// # Errors
231    /// Returns [`RetryConfigError`] when policy text is unsupported or when a
232    /// policy is configured without a timeout and no default timeout exists.
233    fn get_attempt_timeout(
234        &self,
235        default: &RetryOptions,
236    ) -> Result<Option<AttemptTimeoutOption>, RetryConfigError> {
237        let default_attempt_timeout = default.attempt_timeout();
238        let policy = self
239            .attempt_timeout_policy
240            .as_deref()
241            .map(parse_attempt_timeout_policy)
242            .transpose()?;
243
244        match self.attempt_timeout_millis {
245            Some(timeout_millis) => {
246                let policy = policy
247                    .or_else(|| {
248                        default_attempt_timeout.map(|attempt_timeout| attempt_timeout.policy())
249                    })
250                    .unwrap_or_default();
251                Ok(Some(AttemptTimeoutOption::new(
252                    Duration::from_millis(timeout_millis),
253                    policy,
254                )))
255            }
256            None => {
257                if let Some(policy) = policy {
258                    let Some(default_attempt_timeout) = default_attempt_timeout else {
259                        return Err(RetryConfigError::invalid_value(
260                            KEY_ATTEMPT_TIMEOUT_POLICY,
261                            "attempt_timeout_policy requires attempt_timeout_millis when the default has no attempt timeout",
262                        ));
263                    };
264                    Ok(Some(default_attempt_timeout.with_policy(policy)))
265                } else {
266                    Ok(default_attempt_timeout)
267                }
268            }
269        }
270    }
271
272    /// Resolves the worker cancellation grace period.
273    ///
274    /// # Parameters
275    /// - `default`: Default options used when the config key is absent.
276    ///
277    /// # Returns
278    /// Configured grace duration, or the default option's grace duration.
279    ///
280    /// # Errors
281    /// This method does not return errors because the raw config value was read
282    /// as an unsigned integer before this method is called.
283    fn get_worker_cancel_grace(&self, default: &RetryOptions) -> Duration {
284        self.worker_cancel_grace_millis
285            .map(Duration::from_millis)
286            .unwrap_or_else(|| default.worker_cancel_grace())
287    }
288
289    /// Resolves the base delay strategy.
290    ///
291    /// # Parameters
292    /// - `default`: Default options used when neither explicit nor implicit
293    ///   delay configuration is present.
294    ///
295    /// # Returns
296    /// The explicit, implicit, or default [`RetryDelay`] strategy.
297    ///
298    /// # Errors
299    /// Returns [`RetryConfigError`] when the explicit delay strategy name is
300    /// unsupported.
301    fn get_delay(&self, default: &RetryOptions) -> Result<RetryDelay, RetryConfigError> {
302        let strategy = self
303            .delay
304            .as_deref()
305            .map(|value| (KEY_DELAY, value))
306            .or_else(|| {
307                self.delay_strategy
308                    .as_deref()
309                    .map(|value| (KEY_DELAY_STRATEGY, value))
310            })
311            .map(|(key, value)| (key, value.trim().to_ascii_lowercase()));
312        match strategy {
313            None => Ok(self
314                .get_implicit_delay()
315                .unwrap_or_else(|| default.delay().clone())),
316            Some((_, strategy)) if strategy == "none" => Ok(RetryDelay::None),
317            Some((_, strategy)) if strategy == "fixed" => {
318                let Some(fixed_delay_millis) = self.fixed_delay_millis else {
319                    return Err(RetryConfigError::invalid_value(
320                        KEY_FIXED_DELAY_MILLIS,
321                        "fixed delay strategy requires fixed_delay_millis",
322                    ));
323                };
324                Ok(RetryDelay::fixed(Duration::from_millis(fixed_delay_millis)))
325            }
326            Some((_, strategy)) if strategy == "random" => Ok(RetryDelay::random(
327                Duration::from_millis(self.random_min_delay_millis.ok_or_else(|| {
328                    RetryConfigError::invalid_value(
329                        KEY_RANDOM_MIN_DELAY_MILLIS,
330                        "random delay strategy requires random_min_delay_millis",
331                    )
332                })?),
333                Duration::from_millis(self.random_max_delay_millis.ok_or_else(|| {
334                    RetryConfigError::invalid_value(
335                        KEY_RANDOM_MAX_DELAY_MILLIS,
336                        "random delay strategy requires random_max_delay_millis",
337                    )
338                })?),
339            )),
340            Some((_, strategy))
341                if strategy == "exponential" || strategy == "exponential_backoff" =>
342            {
343                let initial_delay = self.exponential_initial_delay_millis.ok_or_else(|| {
344                    RetryConfigError::invalid_value(
345                        KEY_EXPONENTIAL_INITIAL_DELAY_MILLIS,
346                        "exponential delay strategy requires exponential_initial_delay_millis",
347                    )
348                })?;
349                let max_delay = self.exponential_max_delay_millis.ok_or_else(|| {
350                    RetryConfigError::invalid_value(
351                        KEY_EXPONENTIAL_MAX_DELAY_MILLIS,
352                        "exponential delay strategy requires exponential_max_delay_millis",
353                    )
354                })?;
355                let multiplier = self.exponential_multiplier.ok_or_else(|| {
356                    RetryConfigError::invalid_value(
357                        KEY_EXPONENTIAL_MULTIPLIER,
358                        "exponential delay strategy requires exponential_multiplier",
359                    )
360                })?;
361                Ok(RetryDelay::exponential(
362                    Duration::from_millis(initial_delay),
363                    Duration::from_millis(max_delay),
364                    multiplier,
365                ))
366            }
367            Some((key, other)) => Err(RetryConfigError::invalid_value(
368                key,
369                format!("unsupported delay strategy '{other}'"),
370            )),
371        }
372    }
373
374    /// Resolves a delay strategy from parameter keys when no strategy name is configured.
375    ///
376    /// # Parameters
377    /// This method has no parameters.
378    ///
379    /// # Returns
380    /// `Some(RetryDelay)` when any delay parameter key is present; otherwise `None`.
381    ///
382    /// # Errors
383    /// This method does not return errors because all config reads have already
384    /// succeeded.
385    fn get_implicit_delay(&self) -> Option<RetryDelay> {
386        if let Some(millis) = self.fixed_delay_millis {
387            return Some(RetryDelay::fixed(Duration::from_millis(millis)));
388        }
389        if self.random_min_delay_millis.is_some() || self.random_max_delay_millis.is_some() {
390            return Some(RetryDelay::random(
391                Duration::from_millis(
392                    self.random_min_delay_millis
393                        .unwrap_or(DEFAULT_RETRY_RANDOM_MIN_DELAY_MILLIS),
394                ),
395                Duration::from_millis(
396                    self.random_max_delay_millis
397                        .unwrap_or(DEFAULT_RETRY_RANDOM_MAX_DELAY_MILLIS),
398                ),
399            ));
400        }
401        if self.exponential_initial_delay_millis.is_some()
402            || self.exponential_max_delay_millis.is_some()
403            || self.exponential_multiplier.is_some()
404        {
405            return Some(RetryDelay::exponential(
406                Duration::from_millis(
407                    self.exponential_initial_delay_millis
408                        .unwrap_or(DEFAULT_RETRY_EXPONENTIAL_INITIAL_DELAY_MILLIS),
409                ),
410                Duration::from_millis(
411                    self.exponential_max_delay_millis
412                        .unwrap_or(DEFAULT_RETRY_EXPONENTIAL_MAX_DELAY_MILLIS),
413                ),
414                self.exponential_multiplier
415                    .unwrap_or(DEFAULT_RETRY_EXPONENTIAL_MULTIPLIER),
416            ));
417        }
418        None
419    }
420
421    /// Resolves the jitter strategy.
422    ///
423    /// # Parameters
424    /// - `default`: Default options used when no jitter key is present or the
425    ///   jitter factor key is absent.
426    ///
427    /// # Returns
428    /// The configured or default [`RetryJitter`] strategy.
429    ///
430    /// # Errors
431    /// This method does not return errors. RetryJitter value validation is handled
432    /// by [`RetryOptions::new`].
433    fn get_jitter(&self, default: &RetryOptions) -> RetryJitter {
434        match self.jitter_factor {
435            Some(factor) if factor == DEFAULT_RETRY_JITTER_FACTOR => RetryJitter::None,
436            None => default.jitter(),
437            Some(factor) => RetryJitter::Factor(factor),
438        }
439    }
440}
441
442/// Parses a configured attempt-timeout policy.
443///
444/// # Parameters
445/// - `value`: Raw policy text read from configuration.
446///
447/// # Returns
448/// A parsed [`AttemptTimeoutPolicy`].
449///
450/// # Errors
451/// Returns [`RetryConfigError`] when the policy text is unsupported.
452fn parse_attempt_timeout_policy(value: &str) -> Result<AttemptTimeoutPolicy, RetryConfigError> {
453    AttemptTimeoutPolicy::from_str(value)
454        .map_err(|message| RetryConfigError::invalid_value(KEY_ATTEMPT_TIMEOUT_POLICY, message))
455}