use std::str::FromStr;
use std::time::Duration;
use http::StatusCode;
use qubit_config::{ConfigReader, ConfigResult};
use qubit_retry::{RetryDelay, RetryJitter, RetryOptions};
use super::http_retry_method_policy::HttpRetryMethodPolicy;
use super::HttpConfigError;
use crate::{HttpError, HttpErrorKind, HttpRequest, HttpResult};
const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 3;
const DEFAULT_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(200);
const DEFAULT_RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
const DEFAULT_RETRY_MULTIPLIER: f64 = 2.0;
const DEFAULT_RETRY_JITTER_FACTOR: f64 = 0.1;
#[derive(Debug, Clone, PartialEq)]
pub struct HttpRetryOptions {
pub enabled: bool,
pub max_attempts: u32,
pub max_duration: Option<Duration>,
pub delay_strategy: RetryDelay,
pub jitter_factor: f64,
pub method_policy: HttpRetryMethodPolicy,
pub retry_status_codes: Option<Vec<StatusCode>>,
pub retry_error_kinds: Option<Vec<HttpErrorKind>>,
}
fn is_retryable_status(status: StatusCode, retry_status_codes: Option<&[StatusCode]>) -> bool {
if let Some(status_codes) = retry_status_codes {
status_codes.contains(&status)
} else {
default_retryable_status(status)
}
}
fn is_retryable_error_kind(
kind: HttpErrorKind,
retry_error_kinds: Option<&[HttpErrorKind]>,
) -> bool {
if let Some(error_kinds) = retry_error_kinds {
error_kinds.contains(&kind)
} else {
default_retryable_error_kind(kind)
}
}
impl HttpRetryOptions {
pub fn new() -> Self {
Self::default()
}
pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
where
R: ConfigReader + ?Sized,
{
let raw = Self::read_config(config).map_err(HttpConfigError::from)?;
let mut opts = Self::default();
if let Some(enabled) = raw.enabled {
opts.enabled = enabled;
}
if let Some(max_attempts) = raw.max_attempts {
opts.max_attempts = max_attempts;
}
opts.max_duration = raw.max_duration;
if let Some(jitter_factor) = raw.jitter_factor {
opts.jitter_factor = jitter_factor;
}
if let Some(method_policy) = raw.method_policy.as_ref() {
opts.method_policy = HttpRetryMethodPolicy::from_config_value(method_policy)?;
}
if let Some(status_codes) = raw.status_codes.as_ref() {
opts.retry_status_codes = Some(parse_retry_status_codes(status_codes)?);
}
if let Some(error_kinds) = raw.error_kinds.as_ref() {
opts.retry_error_kinds = Some(parse_retry_error_kinds(error_kinds)?);
}
if let Some(delay_strategy) = raw.delay_strategy.as_ref() {
opts.delay_strategy = parse_retry_delay_strategy(delay_strategy, &raw)?;
}
opts.validate()?;
Ok(opts)
}
fn read_config<R>(config: &R) -> ConfigResult<HttpRetryConfigInput>
where
R: ConfigReader + ?Sized,
{
Ok(HttpRetryConfigInput {
enabled: config.get_optional("enabled")?,
max_attempts: config.get_optional("max_attempts")?,
max_duration: config.get_optional("max_duration")?,
delay_strategy: config.get_optional_string("delay_strategy")?,
fixed_delay: config.get_optional("fixed_delay")?,
random_min_delay: config.get_optional("random_min_delay")?,
random_max_delay: config.get_optional("random_max_delay")?,
backoff_initial_delay: config.get_optional("backoff_initial_delay")?,
backoff_max_delay: config.get_optional("backoff_max_delay")?,
backoff_multiplier: config.get_optional("backoff_multiplier")?,
jitter_factor: config.get_optional("jitter_factor")?,
method_policy: config.get_optional_string("method_policy")?,
status_codes: config.get_optional_string_list("status_codes")?,
error_kinds: config.get_optional_string_list("error_kinds")?,
})
}
pub fn validate(&self) -> Result<(), HttpConfigError> {
if self.max_attempts == 0 {
return Err(HttpConfigError::invalid_value(
"max_attempts",
"Retry max_attempts must be greater than 0",
));
}
if !(0.0..=1.0).contains(&self.jitter_factor) {
return Err(HttpConfigError::invalid_value(
"jitter_factor",
"Retry jitter_factor must be between 0.0 and 1.0",
));
}
self.delay_strategy
.validate()
.map_err(|message| HttpConfigError::invalid_value("delay_strategy", message))?;
Ok(())
}
pub fn allows_method(&self, method: &http::Method) -> bool {
self.enabled && self.method_policy.allows_method(method)
}
pub fn should_retry(&self, request: &HttpRequest) -> bool {
self.max_attempts > 1 && self.allows_method(request.method())
}
pub fn resolve(&self, request: &HttpRequest) -> Self {
let mut options = self.clone();
options.enabled = request.retry_override().resolve_enabled(options.enabled);
options.method_policy = request
.retry_override()
.resolve_method_policy(options.method_policy);
options
}
pub fn is_retryable_status(&self, status: StatusCode) -> bool {
is_retryable_status(status, self.retry_status_codes.as_deref())
}
pub fn is_retryable_error_kind(&self, kind: HttpErrorKind) -> bool {
is_retryable_error_kind(kind, self.retry_error_kinds.as_deref())
}
pub(crate) fn to_executor_options(&self) -> HttpResult<RetryOptions> {
RetryOptions::new(
self.max_attempts,
None,
self.max_duration,
self.delay_strategy.clone(),
RetryJitter::factor(self.jitter_factor),
)
.map_err(|error| HttpError::other(format!("Invalid HTTP retry options: {error}")))
}
}
impl Default for HttpRetryOptions {
fn default() -> Self {
Self {
enabled: false,
max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
max_duration: None,
delay_strategy: RetryDelay::Exponential {
initial: DEFAULT_RETRY_INITIAL_DELAY,
max: DEFAULT_RETRY_MAX_DELAY,
multiplier: DEFAULT_RETRY_MULTIPLIER,
},
jitter_factor: DEFAULT_RETRY_JITTER_FACTOR,
method_policy: HttpRetryMethodPolicy::default(),
retry_status_codes: None,
retry_error_kinds: None,
}
}
}
struct HttpRetryConfigInput {
enabled: Option<bool>,
max_attempts: Option<u32>,
max_duration: Option<Duration>,
delay_strategy: Option<String>,
fixed_delay: Option<Duration>,
random_min_delay: Option<Duration>,
random_max_delay: Option<Duration>,
backoff_initial_delay: Option<Duration>,
backoff_max_delay: Option<Duration>,
backoff_multiplier: Option<f64>,
jitter_factor: Option<f64>,
method_policy: Option<String>,
status_codes: Option<Vec<String>>,
error_kinds: Option<Vec<String>>,
}
fn parse_retry_delay_strategy(
value: &str,
raw: &HttpRetryConfigInput,
) -> Result<RetryDelay, HttpConfigError> {
let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"none" => Ok(RetryDelay::None),
"fixed" => Ok(RetryDelay::Fixed(
raw.fixed_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
)),
"random" => Ok(RetryDelay::Random {
min: raw.random_min_delay.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
max: raw.random_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
}),
"exponential_backoff" | "exponential" => Ok(RetryDelay::Exponential {
initial: raw
.backoff_initial_delay
.unwrap_or(DEFAULT_RETRY_INITIAL_DELAY),
max: raw.backoff_max_delay.unwrap_or(DEFAULT_RETRY_MAX_DELAY),
multiplier: raw.backoff_multiplier.unwrap_or(DEFAULT_RETRY_MULTIPLIER),
}),
_ => Err(HttpConfigError::invalid_value(
"delay_strategy",
format!("Unsupported retry delay strategy: {value}"),
)),
}
}
fn parse_retry_status_codes(values: &[String]) -> Result<Vec<StatusCode>, HttpConfigError> {
let mut result = Vec::<StatusCode>::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(HttpConfigError::invalid_value(
"status_codes",
"Retry status_codes cannot contain blank values",
));
}
let raw_code = trimmed.parse::<u16>().map_err(|error| {
HttpConfigError::invalid_value(
"status_codes",
format!("Invalid retry status code '{trimmed}': {error}"),
)
})?;
if !(100..=599).contains(&raw_code) {
return Err(HttpConfigError::invalid_value(
"status_codes",
format!("Retry status code must be in range 100..=599, got {raw_code}"),
));
}
let status = StatusCode::from_u16(raw_code)
.expect("retry status code range is pre-validated to 100..=599");
if !result.contains(&status) {
result.push(status);
}
}
result.sort_by_key(|status| status.as_u16());
Ok(result)
}
fn parse_retry_error_kinds(values: &[String]) -> Result<Vec<HttpErrorKind>, HttpConfigError> {
let mut result = Vec::<HttpErrorKind>::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(HttpConfigError::invalid_value(
"error_kinds",
"Retry error_kinds cannot contain blank values",
));
}
let normalized = trimmed.replace('-', "_");
let kind = HttpErrorKind::from_str(&normalized).map_err(|_| {
HttpConfigError::invalid_value(
"error_kinds",
format!("Unsupported retry error kind: {trimmed}"),
)
})?;
if !result.contains(&kind) {
result.push(kind);
}
}
Ok(result)
}
fn default_retryable_status(status: StatusCode) -> bool {
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
}
fn default_retryable_error_kind(kind: HttpErrorKind) -> bool {
matches!(
kind,
HttpErrorKind::ConnectTimeout
| HttpErrorKind::ReadTimeout
| HttpErrorKind::WriteTimeout
| HttpErrorKind::RequestTimeout
| HttpErrorKind::Transport
)
}
#[cfg(coverage)]
#[doc(hidden)]
pub(crate) fn coverage_exercise_retry_option_paths() -> String {
let options = HttpRetryOptions {
jitter_factor: 2.0,
..HttpRetryOptions::default()
};
options
.to_executor_options()
.expect_err("invalid jitter should fail executor option conversion")
.message
}