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(kind: HttpErrorKind, retry_error_kinds: Option<&[HttpErrorKind]>) -> bool {
78    if let Some(error_kinds) = retry_error_kinds {
79        error_kinds.contains(&kind)
80    } else {
81        default_retryable_error_kind(kind)
82    }
83}
84
85impl HttpRetryOptions {
86    /// Creates default retry options.
87    ///
88    /// # Returns
89    /// Fresh retry options with built-in retry disabled.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Creates [`HttpRetryOptions`] from `config` using relative keys.
95    ///
96    /// # Parameters
97    /// - `config`: Any [`ConfigReader`] scoped to the retry section.
98    ///
99    /// # Returns
100    /// Parsed retry options or [`HttpConfigError`].
101    pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
102    where
103        R: ConfigReader + ?Sized,
104    {
105        let raw = Self::read_config(config).map_err(HttpConfigError::from)?;
106        let mut opts = Self::default();
107
108        if let Some(enabled) = raw.enabled {
109            opts.enabled = enabled;
110        }
111        if let Some(max_attempts) = raw.max_attempts {
112            opts.max_attempts = max_attempts;
113        }
114        opts.max_duration = raw.max_duration;
115        if let Some(jitter_factor) = raw.jitter_factor {
116            opts.jitter_factor = jitter_factor;
117        }
118        if let Some(method_policy) = raw.method_policy.as_ref() {
119            opts.method_policy = HttpRetryMethodPolicy::from_config_value(method_policy)?;
120        }
121        if let Some(status_codes) = raw.status_codes.as_ref() {
122            opts.retry_status_codes = Some(parse_retry_status_codes(status_codes)?);
123        }
124        if let Some(error_kinds) = raw.error_kinds.as_ref() {
125            opts.retry_error_kinds = Some(parse_retry_error_kinds(error_kinds)?);
126        }
127
128        if let Some(delay_strategy) = raw.delay_strategy.as_ref() {
129            opts.delay_strategy = parse_retry_delay_strategy(delay_strategy, &raw)?;
130        }
131
132        opts.validate()?;
133        Ok(opts)
134    }
135
136    /// Reads retry options from a `ConfigReader`.
137    ///
138    /// # Parameters
139    /// - `config`: Configuration reader whose keys are relative to the retry
140    ///   configuration prefix.
141    ///
142    /// # Returns
143    /// Parsed retry options or [`HttpConfigError`].
144    fn read_config<R>(config: &R) -> ConfigResult<HttpRetryConfigInput>
145    where
146        R: ConfigReader + ?Sized,
147    {
148        Ok(HttpRetryConfigInput {
149            enabled: config.get_optional("enabled")?,
150            max_attempts: config.get_optional("max_attempts")?,
151            max_duration: config.get_optional("max_duration")?,
152            delay_strategy: config.get_optional_string("delay_strategy")?,
153            fixed_delay: config.get_optional("fixed_delay")?,
154            random_min_delay: config.get_optional("random_min_delay")?,
155            random_max_delay: config.get_optional("random_max_delay")?,
156            backoff_initial_delay: config.get_optional("backoff_initial_delay")?,
157            backoff_max_delay: config.get_optional("backoff_max_delay")?,
158            backoff_multiplier: config.get_optional("backoff_multiplier")?,
159            jitter_factor: config.get_optional("jitter_factor")?,
160            method_policy: config.get_optional_string("method_policy")?,
161            status_codes: config.get_optional_string_list("status_codes")?,
162            error_kinds: config.get_optional_string_list("error_kinds")?,
163        })
164    }
165
166    /// Runs retry option validation.
167    ///
168    /// # Returns
169    /// `Ok(())` when values are usable, otherwise [`HttpConfigError`].
170    pub fn validate(&self) -> Result<(), HttpConfigError> {
171        if self.max_attempts == 0 {
172            return Err(HttpConfigError::invalid_value(
173                "max_attempts",
174                "Retry max_attempts must be greater than 0",
175            ));
176        }
177        if !(0.0..=1.0).contains(&self.jitter_factor) {
178            return Err(HttpConfigError::invalid_value(
179                "jitter_factor",
180                "Retry jitter_factor must be between 0.0 and 1.0",
181            ));
182        }
183        self.delay_strategy
184            .validate()
185            .map_err(|message| HttpConfigError::invalid_value("delay_strategy", message))?;
186        Ok(())
187    }
188
189    /// Returns whether `method` is eligible for built-in retry.
190    ///
191    /// # Parameters
192    /// - `method`: HTTP method to evaluate.
193    ///
194    /// # Returns
195    /// `true` if retry is enabled and the method policy allows replay.
196    pub fn allows_method(&self, method: &http::Method) -> bool {
197        self.enabled && self.method_policy.allows_method(method)
198    }
199
200    /// Returns whether retry should run for `request` under this policy.
201    ///
202    /// # Parameters
203    /// - `request`: Request whose method is checked against retry policy.
204    ///
205    /// # Returns
206    /// `true` when retry is enabled, `max_attempts` is greater than one, and
207    /// the request method is allowed by [`Self::method_policy`].
208    pub fn should_retry(&self, request: &HttpRequest) -> bool {
209        self.max_attempts > 1 && self.allows_method(request.method())
210    }
211
212    /// Resolves request-level retry override against this retry policy.
213    ///
214    /// # Parameters
215    /// - `request`: Request whose retry override is applied.
216    ///
217    /// # Returns
218    /// Effective retry options for this request.
219    pub fn resolve(&self, request: &HttpRequest) -> Self {
220        let mut options = self.clone();
221        options.enabled = request.retry_override().resolve_enabled(options.enabled);
222        options.method_policy = request.retry_override().resolve_method_policy(options.method_policy);
223        options
224    }
225
226    /// Returns whether a status code is retryable under current retry policy.
227    ///
228    /// # Parameters
229    /// - `status`: HTTP status code from the failure response.
230    ///
231    /// # Returns
232    /// `true` if status should be retried.
233    pub fn is_retryable_status(&self, status: StatusCode) -> bool {
234        is_retryable_status(status, self.retry_status_codes.as_deref())
235    }
236
237    /// Returns whether a non-status error kind is retryable under current retry
238    /// policy.
239    ///
240    /// # Parameters
241    /// - `kind`: Error kind to evaluate.
242    ///
243    /// # Returns
244    /// `true` if kind should be retried.
245    pub fn is_retryable_error_kind(&self, kind: HttpErrorKind) -> bool {
246        is_retryable_error_kind(kind, self.retry_error_kinds.as_deref())
247    }
248
249    /// Converts these options into [`RetryOptions`] for the built-in retry executor.
250    ///
251    /// HTTP retry has one externally visible duration budget: [`Self::max_duration`].
252    /// It maps to `qubit-retry`'s `max_total_elapsed`, so the budget includes
253    /// attempt time, retry sleeps, `Retry-After` sleeps, and retry control-path
254    /// listener time measured with monotonic time.
255    ///
256    /// # Panics
257    /// Panics only if options that already passed [`Self::validate`] cannot be
258    /// represented by `qubit-retry`.
259    pub(crate) fn to_executor_options(&self) -> RetryOptions {
260        RetryOptions::new(
261            self.max_attempts,
262            None,
263            self.max_duration,
264            self.delay_strategy.clone(),
265            RetryJitter::factor(self.jitter_factor),
266        )
267        .expect("validated HTTP retry options should convert to retry executor options")
268    }
269}
270
271impl Default for HttpRetryOptions {
272    fn default() -> Self {
273        Self {
274            enabled: false,
275            max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
276            max_duration: None,
277            delay_strategy: RetryDelay::Exponential {
278                initial: DEFAULT_RETRY_INITIAL_DELAY,
279                max: DEFAULT_RETRY_MAX_DELAY,
280                multiplier: DEFAULT_RETRY_MULTIPLIER,
281            },
282            jitter_factor: DEFAULT_RETRY_JITTER_FACTOR,
283            method_policy: HttpRetryMethodPolicy::default(),
284            retry_status_codes: None,
285            retry_error_kinds: None,
286        }
287    }
288}
289
290struct HttpRetryConfigInput {
291    enabled: Option<bool>,
292    max_attempts: Option<u32>,
293    max_duration: Option<Duration>,
294    delay_strategy: Option<String>,
295    fixed_delay: Option<Duration>,
296    random_min_delay: Option<Duration>,
297    random_max_delay: Option<Duration>,
298    backoff_initial_delay: Option<Duration>,
299    backoff_max_delay: Option<Duration>,
300    backoff_multiplier: Option<f64>,
301    jitter_factor: Option<f64>,
302    method_policy: Option<String>,
303    status_codes: Option<Vec<String>>,
304    error_kinds: Option<Vec<String>>,
305}
306
307fn parse_retry_delay_strategy(value: &str, raw: &HttpRetryConfigInput) -> Result<RetryDelay, HttpConfigError> {
308    let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
309    match normalized.as_str() {
310        "none" => Ok(RetryDelay::None),
311        "fixed" => Ok(RetryDelay::Fixed(
312            raw.fixed_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
313        )),
314        "random" => Ok(RetryDelay::Random {
315            min: raw.random_min_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
316            max: raw.random_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
317        }),
318        "exponential_backoff" | "exponential" => Ok(RetryDelay::Exponential {
319            initial: raw.backoff_initial_delay.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).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("error_kinds", format!("Unsupported retry error kind: {trimmed}"))
395        })?;
396        if !result.contains(&kind) {
397            result.push(kind);
398        }
399    }
400    Ok(result)
401}
402
403/// Returns default retryable status policy when no explicit status allowlist is
404/// configured.
405///
406/// # Parameters
407/// - `status`: HTTP status code to evaluate.
408///
409/// # Returns
410/// `true` for `429` and `5xx`, otherwise `false`.
411fn default_retryable_status(status: StatusCode) -> bool {
412    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
413}
414
415/// Returns default retryable non-status error-kind policy when no explicit
416/// error-kind allowlist is configured.
417///
418/// # Parameters
419/// - `kind`: Error kind to evaluate.
420///
421/// # Returns
422/// `true` for timeout and transport failures, otherwise `false`.
423fn default_retryable_error_kind(kind: HttpErrorKind) -> bool {
424    matches!(
425        kind,
426        HttpErrorKind::ConnectTimeout
427            | HttpErrorKind::ReadTimeout
428            | HttpErrorKind::WriteTimeout
429            | HttpErrorKind::RequestTimeout
430            | HttpErrorKind::Transport
431    )
432}