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