Skip to main content

qubit_http/options/
http_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
11use std::str::FromStr;
12use std::time::Duration;
13
14use http::StatusCode;
15use qubit_config::{ConfigReader, ConfigResult};
16use qubit_retry::{RetryDelay, RetryJitter, RetryOptions};
17
18use super::http_retry_method_policy::HttpRetryMethodPolicy;
19use super::HttpConfigError;
20use crate::{HttpError, HttpErrorKind, HttpRequest, HttpResult};
21
22const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 3;
23const DEFAULT_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(200);
24const DEFAULT_RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
25const DEFAULT_RETRY_MULTIPLIER: f64 = 2.0;
26const DEFAULT_RETRY_JITTER_FACTOR: f64 = 0.1;
27
28/// Retry settings for [`crate::HttpClient`].
29#[derive(Debug, Clone, PartialEq)]
30pub struct HttpRetryOptions {
31    /// Whether built-in retry is enabled.
32    pub enabled: bool,
33    /// Maximum number of attempts, including the first request.
34    pub max_attempts: u32,
35    /// Optional maximum total retry duration.
36    pub max_duration: Option<Duration>,
37    /// Delay strategy between attempts.
38    pub delay_strategy: RetryDelay,
39    /// Jitter factor passed to the retry delay strategy.
40    pub jitter_factor: f64,
41    /// Method replay policy.
42    pub method_policy: HttpRetryMethodPolicy,
43    /// Optional retryable status-code allowlist.
44    ///
45    /// When set, only listed statuses are retryable for status errors.
46    pub retry_status_codes: Option<Vec<StatusCode>>,
47    /// Optional retryable error-kind allowlist for non-status failures.
48    ///
49    /// When set, only listed kinds are retryable for non-status errors.
50    pub retry_error_kinds: Option<Vec<HttpErrorKind>>,
51}
52
53/// Returns whether `status` is retryable for the given optional allowlist.
54///
55/// When `retry_status_codes` is `None`, uses [`default_retryable_status`].
56fn is_retryable_status(status: StatusCode, retry_status_codes: Option<&[StatusCode]>) -> bool {
57    if let Some(status_codes) = retry_status_codes {
58        status_codes.contains(&status)
59    } else {
60        default_retryable_status(status)
61    }
62}
63
64/// Returns whether `kind` is retryable for the given optional allowlist.
65///
66/// When `retry_error_kinds` is `None`, uses [`default_retryable_error_kind`].
67fn is_retryable_error_kind(
68    kind: HttpErrorKind,
69    retry_error_kinds: Option<&[HttpErrorKind]>,
70) -> bool {
71    if let Some(error_kinds) = retry_error_kinds {
72        error_kinds.contains(&kind)
73    } else {
74        default_retryable_error_kind(kind)
75    }
76}
77
78impl HttpRetryOptions {
79    /// Creates default retry options.
80    ///
81    /// # Returns
82    /// Fresh retry options with built-in retry disabled.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Creates [`HttpRetryOptions`] from `config` using relative keys.
88    ///
89    /// # Parameters
90    /// - `config`: Any [`ConfigReader`] scoped to the retry section.
91    ///
92    /// # Returns
93    /// Parsed retry options or [`HttpConfigError`].
94    pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
95    where
96        R: ConfigReader + ?Sized,
97    {
98        let raw = Self::read_config(config).map_err(HttpConfigError::from)?;
99        let mut opts = Self::default();
100
101        if let Some(enabled) = raw.enabled {
102            opts.enabled = enabled;
103        }
104        if let Some(max_attempts) = raw.max_attempts {
105            opts.max_attempts = max_attempts;
106        }
107        opts.max_duration = raw.max_duration;
108        if let Some(jitter_factor) = raw.jitter_factor {
109            opts.jitter_factor = jitter_factor;
110        }
111        if let Some(method_policy) = raw.method_policy.as_ref() {
112            opts.method_policy = HttpRetryMethodPolicy::from_config_value(method_policy)?;
113        }
114        if let Some(status_codes) = raw.status_codes.as_ref() {
115            opts.retry_status_codes = Some(parse_retry_status_codes(status_codes)?);
116        }
117        if let Some(error_kinds) = raw.error_kinds.as_ref() {
118            opts.retry_error_kinds = Some(parse_retry_error_kinds(error_kinds)?);
119        }
120
121        if let Some(delay_strategy) = raw.delay_strategy.as_ref() {
122            opts.delay_strategy = parse_retry_delay_strategy(delay_strategy, &raw)?;
123        }
124
125        opts.validate()?;
126        Ok(opts)
127    }
128
129    /// Reads retry options from a `ConfigReader`.
130    ///
131    /// # Parameters
132    /// - `config`: Configuration reader whose keys are relative to the retry
133    ///   configuration prefix.
134    ///
135    /// # Returns
136    /// Parsed retry options or [`HttpConfigError`].
137    fn read_config<R>(config: &R) -> ConfigResult<HttpRetryConfigInput>
138    where
139        R: ConfigReader + ?Sized,
140    {
141        Ok(HttpRetryConfigInput {
142            enabled: config.get_optional("enabled")?,
143            max_attempts: config.get_optional("max_attempts")?,
144            max_duration: config.get_optional("max_duration")?,
145            delay_strategy: config.get_optional_string("delay_strategy")?,
146            fixed_delay: config.get_optional("fixed_delay")?,
147            random_min_delay: config.get_optional("random_min_delay")?,
148            random_max_delay: config.get_optional("random_max_delay")?,
149            backoff_initial_delay: config.get_optional("backoff_initial_delay")?,
150            backoff_max_delay: config.get_optional("backoff_max_delay")?,
151            backoff_multiplier: config.get_optional("backoff_multiplier")?,
152            jitter_factor: config.get_optional("jitter_factor")?,
153            method_policy: config.get_optional_string("method_policy")?,
154            status_codes: config.get_optional_string_list("status_codes")?,
155            error_kinds: config.get_optional_string_list("error_kinds")?,
156        })
157    }
158
159    /// Runs retry option validation.
160    ///
161    /// # Returns
162    /// `Ok(())` when values are usable, otherwise [`HttpConfigError`].
163    pub fn validate(&self) -> Result<(), HttpConfigError> {
164        if self.max_attempts == 0 {
165            return Err(HttpConfigError::invalid_value(
166                "max_attempts",
167                "Retry max_attempts must be greater than 0",
168            ));
169        }
170        if !(0.0..=1.0).contains(&self.jitter_factor) {
171            return Err(HttpConfigError::invalid_value(
172                "jitter_factor",
173                "Retry jitter_factor must be between 0.0 and 1.0",
174            ));
175        }
176        self.delay_strategy
177            .validate()
178            .map_err(|message| HttpConfigError::invalid_value("delay_strategy", message))?;
179        Ok(())
180    }
181
182    /// Returns whether `method` is eligible for built-in retry.
183    ///
184    /// # Parameters
185    /// - `method`: HTTP method to evaluate.
186    ///
187    /// # Returns
188    /// `true` if retry is enabled and the method policy allows replay.
189    pub fn allows_method(&self, method: &http::Method) -> bool {
190        self.enabled && self.method_policy.allows_method(method)
191    }
192
193    /// Returns whether retry should run for `request` under this policy.
194    ///
195    /// # Parameters
196    /// - `request`: Request whose method is checked against retry policy.
197    ///
198    /// # Returns
199    /// `true` when retry is enabled, `max_attempts` is greater than one, and
200    /// the request method is allowed by [`Self::method_policy`].
201    pub fn should_retry(&self, request: &HttpRequest) -> bool {
202        self.max_attempts > 1 && self.allows_method(request.method())
203    }
204
205    /// Resolves request-level retry override against this retry policy.
206    ///
207    /// # Parameters
208    /// - `request`: Request whose retry override is applied.
209    ///
210    /// # Returns
211    /// Effective retry options for this request.
212    pub fn resolve(&self, request: &HttpRequest) -> Self {
213        let mut options = self.clone();
214        options.enabled = request.retry_override().resolve_enabled(options.enabled);
215        options.method_policy = request
216            .retry_override()
217            .resolve_method_policy(options.method_policy);
218        options
219    }
220
221    /// Returns whether a status code is retryable under current retry policy.
222    ///
223    /// # Parameters
224    /// - `status`: HTTP status code from the failure response.
225    ///
226    /// # Returns
227    /// `true` if status should be retried.
228    pub fn is_retryable_status(&self, status: StatusCode) -> bool {
229        is_retryable_status(status, self.retry_status_codes.as_deref())
230    }
231
232    /// Returns whether a non-status error kind is retryable under current retry
233    /// policy.
234    ///
235    /// # Parameters
236    /// - `kind`: Error kind to evaluate.
237    ///
238    /// # Returns
239    /// `true` if kind should be retried.
240    pub fn is_retryable_error_kind(&self, kind: HttpErrorKind) -> bool {
241        is_retryable_error_kind(kind, self.retry_error_kinds.as_deref())
242    }
243
244    /// Converts these options into [`RetryOptions`] for the built-in retry executor.
245    ///
246    /// HTTP retry has one externally visible duration budget: [`Self::max_duration`].
247    /// It maps to `qubit-retry`'s `max_total_elapsed`, so the budget includes
248    /// attempt time, retry sleeps, `Retry-After` sleeps, and retry control-path
249    /// listener time measured with monotonic time.
250    ///
251    /// # Errors
252    /// Returns [`HttpError`] when executor limits or delay/jitter settings are invalid.
253    pub(crate) fn to_executor_options(&self) -> HttpResult<RetryOptions> {
254        RetryOptions::new(
255            self.max_attempts,
256            None,
257            self.max_duration,
258            self.delay_strategy.clone(),
259            RetryJitter::factor(self.jitter_factor),
260        )
261        .map_err(|error| HttpError::other(format!("Invalid HTTP retry options: {error}")))
262    }
263}
264
265impl Default for HttpRetryOptions {
266    fn default() -> Self {
267        Self {
268            enabled: false,
269            max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
270            max_duration: None,
271            delay_strategy: RetryDelay::Exponential {
272                initial: DEFAULT_RETRY_INITIAL_DELAY,
273                max: DEFAULT_RETRY_MAX_DELAY,
274                multiplier: DEFAULT_RETRY_MULTIPLIER,
275            },
276            jitter_factor: DEFAULT_RETRY_JITTER_FACTOR,
277            method_policy: HttpRetryMethodPolicy::default(),
278            retry_status_codes: None,
279            retry_error_kinds: None,
280        }
281    }
282}
283
284struct HttpRetryConfigInput {
285    enabled: Option<bool>,
286    max_attempts: Option<u32>,
287    max_duration: Option<Duration>,
288    delay_strategy: Option<String>,
289    fixed_delay: Option<Duration>,
290    random_min_delay: Option<Duration>,
291    random_max_delay: Option<Duration>,
292    backoff_initial_delay: Option<Duration>,
293    backoff_max_delay: Option<Duration>,
294    backoff_multiplier: Option<f64>,
295    jitter_factor: Option<f64>,
296    method_policy: Option<String>,
297    status_codes: Option<Vec<String>>,
298    error_kinds: Option<Vec<String>>,
299}
300
301fn parse_retry_delay_strategy(
302    value: &str,
303    raw: &HttpRetryConfigInput,
304) -> Result<RetryDelay, HttpConfigError> {
305    let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
306    match normalized.as_str() {
307        "none" => Ok(RetryDelay::None),
308        "fixed" => Ok(RetryDelay::Fixed(
309            raw.fixed_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
310        )),
311        "random" => Ok(RetryDelay::Random {
312            min: raw.random_min_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
313            max: raw.random_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
314        }),
315        "exponential_backoff" | "exponential" => Ok(RetryDelay::Exponential {
316            initial: raw
317                .backoff_initial_delay
318                .unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
319            max: raw.backoff_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
320            multiplier: raw.backoff_multiplier.unwrap_or(DEFAULT_RETRY_MULTIPLIER),
321        }),
322        _ => Err(HttpConfigError::invalid_value(
323            "delay_strategy",
324            format!("Unsupported retry delay strategy: {value}"),
325        )),
326    }
327}
328
329/// Parses retry status-code list from config string values.
330///
331/// # Parameters
332/// - `values`: Status-code strings from config.
333///
334/// # Returns
335/// Normalized unique status-code list in ascending order.
336///
337/// # Errors
338/// Returns [`HttpConfigError`] when any entry is blank or not a valid HTTP
339/// status code.
340fn parse_retry_status_codes(values: &[String]) -> Result<Vec<StatusCode>, HttpConfigError> {
341    let mut result = Vec::<StatusCode>::new();
342    for value in values {
343        let trimmed = value.trim();
344        if trimmed.is_empty() {
345            return Err(HttpConfigError::invalid_value(
346                "status_codes",
347                "Retry status_codes cannot contain blank values",
348            ));
349        }
350        let raw_code = trimmed.parse::<u16>().map_err(|error| {
351            HttpConfigError::invalid_value(
352                "status_codes",
353                format!("Invalid retry status code '{trimmed}': {error}"),
354            )
355        })?;
356        if !(100..=599).contains(&raw_code) {
357            return Err(HttpConfigError::invalid_value(
358                "status_codes",
359                format!("Retry status code must be in range 100..=599, got {raw_code}"),
360            ));
361        }
362        let status = StatusCode::from_u16(raw_code)
363            .expect("retry status code range is pre-validated to 100..=599");
364        if !result.contains(&status) {
365            result.push(status);
366        }
367    }
368    result.sort_by_key(|status| status.as_u16());
369    Ok(result)
370}
371
372/// Parses retry error-kind list from config string values.
373///
374/// # Parameters
375/// - `values`: Error-kind strings from config.
376///
377/// # Returns
378/// Normalized unique error-kind list.
379///
380/// # Errors
381/// Returns [`HttpConfigError`] when any entry is blank or unsupported.
382fn parse_retry_error_kinds(values: &[String]) -> Result<Vec<HttpErrorKind>, HttpConfigError> {
383    let mut result = Vec::<HttpErrorKind>::new();
384    for value in values {
385        let trimmed = value.trim();
386        if trimmed.is_empty() {
387            return Err(HttpConfigError::invalid_value(
388                "error_kinds",
389                "Retry error_kinds cannot contain blank values",
390            ));
391        }
392        let normalized = trimmed.replace('-', "_");
393        let kind = HttpErrorKind::from_str(&normalized).map_err(|_| {
394            HttpConfigError::invalid_value(
395                "error_kinds",
396                format!("Unsupported retry error kind: {trimmed}"),
397            )
398        })?;
399        if !result.contains(&kind) {
400            result.push(kind);
401        }
402    }
403    Ok(result)
404}
405
406/// Returns default retryable status policy when no explicit status allowlist is
407/// configured.
408///
409/// # Parameters
410/// - `status`: HTTP status code to evaluate.
411///
412/// # Returns
413/// `true` for `429` and `5xx`, otherwise `false`.
414fn default_retryable_status(status: StatusCode) -> bool {
415    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
416}
417
418/// Returns default retryable non-status error-kind policy when no explicit
419/// error-kind allowlist is configured.
420///
421/// # Parameters
422/// - `kind`: Error kind to evaluate.
423///
424/// # Returns
425/// `true` for timeout and transport failures, otherwise `false`.
426fn default_retryable_error_kind(kind: HttpErrorKind) -> bool {
427    matches!(
428        kind,
429        HttpErrorKind::ConnectTimeout
430            | HttpErrorKind::ReadTimeout
431            | HttpErrorKind::WriteTimeout
432            | HttpErrorKind::RequestTimeout
433            | HttpErrorKind::Transport
434    )
435}
436
437/// Exercises retry option conversion error paths for coverage-only tests.
438///
439/// # Returns
440/// Error message produced by executor option conversion.
441#[cfg(coverage)]
442#[doc(hidden)]
443pub(crate) fn coverage_exercise_retry_option_paths() -> String {
444    let options = HttpRetryOptions {
445        jitter_factor: 2.0,
446        ..HttpRetryOptions::default()
447    };
448    options
449        .to_executor_options()
450        .expect_err("invalid jitter should fail executor option conversion")
451        .message
452}