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::{HttpErrorKind, HttpRequest};
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    /// # Panics
252    /// Panics only if options that already passed [`Self::validate`] cannot be
253    /// represented by `qubit-retry`.
254    pub(crate) fn to_executor_options(&self) -> RetryOptions {
255        RetryOptions::new(
256            self.max_attempts,
257            None,
258            self.max_duration,
259            self.delay_strategy.clone(),
260            RetryJitter::factor(self.jitter_factor),
261        )
262        .expect("validated HTTP retry options should convert to retry executor options")
263    }
264}
265
266impl Default for HttpRetryOptions {
267    fn default() -> Self {
268        Self {
269            enabled: false,
270            max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
271            max_duration: None,
272            delay_strategy: RetryDelay::Exponential {
273                initial: DEFAULT_RETRY_INITIAL_DELAY,
274                max: DEFAULT_RETRY_MAX_DELAY,
275                multiplier: DEFAULT_RETRY_MULTIPLIER,
276            },
277            jitter_factor: DEFAULT_RETRY_JITTER_FACTOR,
278            method_policy: HttpRetryMethodPolicy::default(),
279            retry_status_codes: None,
280            retry_error_kinds: None,
281        }
282    }
283}
284
285struct HttpRetryConfigInput {
286    enabled: Option<bool>,
287    max_attempts: Option<u32>,
288    max_duration: Option<Duration>,
289    delay_strategy: Option<String>,
290    fixed_delay: Option<Duration>,
291    random_min_delay: Option<Duration>,
292    random_max_delay: Option<Duration>,
293    backoff_initial_delay: Option<Duration>,
294    backoff_max_delay: Option<Duration>,
295    backoff_multiplier: Option<f64>,
296    jitter_factor: Option<f64>,
297    method_policy: Option<String>,
298    status_codes: Option<Vec<String>>,
299    error_kinds: Option<Vec<String>>,
300}
301
302fn parse_retry_delay_strategy(
303    value: &str,
304    raw: &HttpRetryConfigInput,
305) -> Result<RetryDelay, HttpConfigError> {
306    let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
307    match normalized.as_str() {
308        "none" => Ok(RetryDelay::None),
309        "fixed" => Ok(RetryDelay::Fixed(
310            raw.fixed_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
311        )),
312        "random" => Ok(RetryDelay::Random {
313            min: raw.random_min_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
314            max: raw.random_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
315        }),
316        "exponential_backoff" | "exponential" => Ok(RetryDelay::Exponential {
317            initial: raw
318                .backoff_initial_delay
319                .unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
320            max: raw.backoff_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
321            multiplier: raw.backoff_multiplier.unwrap_or(DEFAULT_RETRY_MULTIPLIER),
322        }),
323        _ => Err(HttpConfigError::invalid_value(
324            "delay_strategy",
325            format!("Unsupported retry delay strategy: {value}"),
326        )),
327    }
328}
329
330/// Parses retry status-code list from config string values.
331///
332/// # Parameters
333/// - `values`: Status-code strings from config.
334///
335/// # Returns
336/// Normalized unique status-code list in ascending order.
337///
338/// # Errors
339/// Returns [`HttpConfigError`] when any entry is blank or not a valid HTTP
340/// status code.
341fn parse_retry_status_codes(values: &[String]) -> Result<Vec<StatusCode>, HttpConfigError> {
342    let mut result = Vec::<StatusCode>::new();
343    for value in values {
344        let trimmed = value.trim();
345        if trimmed.is_empty() {
346            return Err(HttpConfigError::invalid_value(
347                "status_codes",
348                "Retry status_codes cannot contain blank values",
349            ));
350        }
351        let raw_code = trimmed.parse::<u16>().map_err(|error| {
352            HttpConfigError::invalid_value(
353                "status_codes",
354                format!("Invalid retry status code '{trimmed}': {error}"),
355            )
356        })?;
357        if !(100..=599).contains(&raw_code) {
358            return Err(HttpConfigError::invalid_value(
359                "status_codes",
360                format!("Retry status code must be in range 100..=599, got {raw_code}"),
361            ));
362        }
363        let status = StatusCode::from_u16(raw_code)
364            .expect("retry status code range is pre-validated to 100..=599");
365        if !result.contains(&status) {
366            result.push(status);
367        }
368    }
369    result.sort_by_key(|status| status.as_u16());
370    Ok(result)
371}
372
373/// Parses retry error-kind list from config string values.
374///
375/// # Parameters
376/// - `values`: Error-kind strings from config.
377///
378/// # Returns
379/// Normalized unique error-kind list.
380///
381/// # Errors
382/// Returns [`HttpConfigError`] when any entry is blank or unsupported.
383fn parse_retry_error_kinds(values: &[String]) -> Result<Vec<HttpErrorKind>, HttpConfigError> {
384    let mut result = Vec::<HttpErrorKind>::new();
385    for value in values {
386        let trimmed = value.trim();
387        if trimmed.is_empty() {
388            return Err(HttpConfigError::invalid_value(
389                "error_kinds",
390                "Retry error_kinds cannot contain blank values",
391            ));
392        }
393        let normalized = trimmed.replace('-', "_");
394        let kind = HttpErrorKind::from_str(&normalized).map_err(|_| {
395            HttpConfigError::invalid_value(
396                "error_kinds",
397                format!("Unsupported retry error kind: {trimmed}"),
398            )
399        })?;
400        if !result.contains(&kind) {
401            result.push(kind);
402        }
403    }
404    Ok(result)
405}
406
407/// Returns default retryable status policy when no explicit status allowlist is
408/// configured.
409///
410/// # Parameters
411/// - `status`: HTTP status code to evaluate.
412///
413/// # Returns
414/// `true` for `429` and `5xx`, otherwise `false`.
415fn default_retryable_status(status: StatusCode) -> bool {
416    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
417}
418
419/// Returns default retryable non-status error-kind policy when no explicit
420/// error-kind allowlist is configured.
421///
422/// # Parameters
423/// - `kind`: Error kind to evaluate.
424///
425/// # Returns
426/// `true` for timeout and transport failures, otherwise `false`.
427fn default_retryable_error_kind(kind: HttpErrorKind) -> bool {
428    matches!(
429        kind,
430        HttpErrorKind::ConnectTimeout
431            | HttpErrorKind::ReadTimeout
432            | HttpErrorKind::WriteTimeout
433            | HttpErrorKind::RequestTimeout
434            | HttpErrorKind::Transport
435    )
436}