Skip to main content

qubit_http/options/
http_retry_options.rs

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