use std::collections::HashSet;
use std::time::Duration;
pub const DEFAULT_USER_AGENT: &str = concat!("modkit-http/", env!("CARGO_PKG_VERSION"));
pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
const IDEMPOTENCY_KEY_HEADER_LOWER: &str = "idempotency-key";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RetryTrigger {
TransportError,
Timeout,
Status(u16),
NonRetryable,
}
impl RetryTrigger {
pub const TOO_MANY_REQUESTS: Self = Self::Status(429);
pub const REQUEST_TIMEOUT: Self = Self::Status(408);
pub const INTERNAL_SERVER_ERROR: Self = Self::Status(500);
pub const BAD_GATEWAY: Self = Self::Status(502);
pub const SERVICE_UNAVAILABLE: Self = Self::Status(503);
pub const GATEWAY_TIMEOUT: Self = Self::Status(504);
}
#[must_use]
pub fn is_idempotent_method(method: &http::Method) -> bool {
matches!(
*method,
http::Method::GET
| http::Method::HEAD
| http::Method::PUT
| http::Method::DELETE
| http::Method::OPTIONS
| http::Method::TRACE
)
}
#[derive(Debug, Clone)]
pub struct ExponentialBackoff {
pub initial: Duration,
pub max: Duration,
pub multiplier: f64,
pub jitter: bool,
}
impl Default for ExponentialBackoff {
fn default() -> Self {
Self {
initial: Duration::from_millis(100),
max: Duration::from_secs(10),
multiplier: 2.0,
jitter: true,
}
}
}
impl ExponentialBackoff {
#[must_use]
pub fn new(initial: Duration, max: Duration) -> Self {
Self {
initial,
max,
..Default::default()
}
}
#[must_use]
pub fn fast() -> Self {
Self {
initial: Duration::from_millis(1),
max: Duration::from_millis(100),
multiplier: 2.0,
jitter: false,
}
}
#[must_use]
pub fn aggressive() -> Self {
Self {
initial: Duration::from_millis(50),
max: Duration::from_secs(30),
multiplier: 2.0,
jitter: true,
}
}
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: usize,
pub backoff: ExponentialBackoff,
pub always_retry: HashSet<RetryTrigger>,
pub idempotent_retry: HashSet<RetryTrigger>,
pub ignore_retry_after: bool,
pub retry_response_drain_limit: usize,
pub skip_drain_on_retry: bool,
pub idempotency_key_header: Option<http::header::HeaderName>,
}
pub const DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT: usize = 64 * 1024;
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
backoff: ExponentialBackoff::default(),
always_retry: HashSet::from([RetryTrigger::TOO_MANY_REQUESTS]),
idempotent_retry: HashSet::from([
RetryTrigger::TransportError,
RetryTrigger::Timeout,
RetryTrigger::REQUEST_TIMEOUT,
RetryTrigger::INTERNAL_SERVER_ERROR,
RetryTrigger::BAD_GATEWAY,
RetryTrigger::SERVICE_UNAVAILABLE,
RetryTrigger::GATEWAY_TIMEOUT,
]),
ignore_retry_after: false,
retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
skip_drain_on_retry: false,
idempotency_key_header: Some(http::header::HeaderName::from_static(
IDEMPOTENCY_KEY_HEADER_LOWER,
)),
}
}
}
impl RetryConfig {
#[must_use]
pub fn disabled() -> Self {
Self {
max_retries: 0,
..Default::default()
}
}
#[must_use]
pub fn aggressive() -> Self {
Self {
max_retries: 5,
backoff: ExponentialBackoff::aggressive(),
always_retry: HashSet::from([
RetryTrigger::TransportError,
RetryTrigger::Timeout,
RetryTrigger::TOO_MANY_REQUESTS,
RetryTrigger::REQUEST_TIMEOUT,
RetryTrigger::INTERNAL_SERVER_ERROR,
RetryTrigger::BAD_GATEWAY,
RetryTrigger::SERVICE_UNAVAILABLE,
RetryTrigger::GATEWAY_TIMEOUT,
]),
idempotent_retry: HashSet::new(),
ignore_retry_after: false,
retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
skip_drain_on_retry: false,
idempotency_key_header: Some(http::header::HeaderName::from_static(
IDEMPOTENCY_KEY_HEADER_LOWER,
)),
}
}
#[must_use]
pub fn should_retry(
&self,
trigger: RetryTrigger,
method: &http::Method,
has_idempotency_key: bool,
) -> bool {
if self.always_retry.contains(&trigger) {
return true;
}
if self.idempotent_retry.contains(&trigger)
&& (is_idempotent_method(method) || has_idempotency_key)
{
return true;
}
false
}
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub max_concurrent_requests: usize,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_concurrent_requests: 100,
}
}
}
impl RateLimitConfig {
#[must_use]
pub fn unlimited() -> Self {
Self {
max_concurrent_requests: usize::MAX,
}
}
#[must_use]
pub fn conservative() -> Self {
Self {
max_concurrent_requests: 10,
}
}
}
#[derive(Debug, Clone)]
pub struct RedirectConfig {
pub max_redirects: usize,
pub same_origin_only: bool,
pub allowed_redirect_hosts: HashSet<String>,
pub strip_sensitive_headers: bool,
pub allow_https_downgrade: bool,
}
impl Default for RedirectConfig {
fn default() -> Self {
Self {
max_redirects: 10,
same_origin_only: true,
allowed_redirect_hosts: HashSet::new(),
strip_sensitive_headers: true,
allow_https_downgrade: false,
}
}
}
impl RedirectConfig {
#[must_use]
pub fn permissive() -> Self {
Self {
max_redirects: 10,
same_origin_only: false,
allowed_redirect_hosts: HashSet::new(),
strip_sensitive_headers: true,
allow_https_downgrade: false,
}
}
#[must_use]
pub fn disabled() -> Self {
Self {
max_redirects: 0,
..Default::default()
}
}
#[must_use]
pub fn for_testing() -> Self {
Self {
max_redirects: 10,
same_origin_only: false,
allowed_redirect_hosts: HashSet::new(),
strip_sensitive_headers: true, allow_https_downgrade: true, }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum TlsRootConfig {
#[default]
WebPki,
Native,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransportSecurity {
TlsOnly,
#[default]
AllowInsecureHttp,
}
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
pub request_timeout: Duration,
pub total_timeout: Option<Duration>,
pub max_body_size: usize,
pub user_agent: String,
pub retry: Option<RetryConfig>,
pub rate_limit: Option<RateLimitConfig>,
pub transport: TransportSecurity,
pub tls_roots: TlsRootConfig,
pub otel: bool,
pub buffer_capacity: usize,
pub redirect: RedirectConfig,
pub pool_idle_timeout: Option<Duration>,
pub pool_max_idle_per_host: usize,
}
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(30),
total_timeout: None,
max_body_size: 10 * 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: Some(RetryConfig::default()),
rate_limit: Some(RateLimitConfig::default()),
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 1024,
redirect: RedirectConfig::default(),
pool_idle_timeout: Some(Duration::from_secs(90)),
pool_max_idle_per_host: 32,
}
}
}
impl HttpClientConfig {
#[must_use]
pub fn minimal() -> Self {
Self {
request_timeout: Duration::from_secs(10),
total_timeout: None,
max_body_size: 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: None,
rate_limit: None,
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 256,
redirect: RedirectConfig::default(),
pool_idle_timeout: Some(Duration::from_secs(30)),
pool_max_idle_per_host: 8,
}
}
#[must_use]
pub fn infra_default() -> Self {
Self {
request_timeout: Duration::from_mins(1),
total_timeout: None,
max_body_size: 50 * 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: Some(RetryConfig::aggressive()),
rate_limit: Some(RateLimitConfig::default()),
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 1024,
redirect: RedirectConfig::default(),
pool_idle_timeout: Some(Duration::from_mins(2)),
pool_max_idle_per_host: 64,
}
}
#[must_use]
pub fn token_endpoint() -> Self {
Self {
request_timeout: Duration::from_secs(30),
total_timeout: None,
max_body_size: 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: Some(RetryConfig {
max_retries: 3,
always_retry: HashSet::from([
RetryTrigger::TransportError,
RetryTrigger::Timeout,
RetryTrigger::TOO_MANY_REQUESTS,
]),
idempotent_retry: HashSet::new(), ignore_retry_after: false,
retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
idempotency_key_header: None, ..RetryConfig::default()
}),
rate_limit: Some(RateLimitConfig::conservative()),
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 256,
redirect: RedirectConfig::default(),
pool_idle_timeout: Some(Duration::from_mins(1)),
pool_max_idle_per_host: 4,
}
}
#[must_use]
pub fn for_testing() -> Self {
Self {
request_timeout: Duration::from_secs(10),
total_timeout: None,
max_body_size: 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: None,
rate_limit: None,
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 256,
redirect: RedirectConfig::for_testing(),
pool_idle_timeout: Some(Duration::from_secs(10)),
pool_max_idle_per_host: 4,
}
}
#[must_use]
pub fn sse() -> Self {
Self {
request_timeout: Duration::from_hours(24), total_timeout: None,
max_body_size: 10 * 1024 * 1024, user_agent: DEFAULT_USER_AGENT.to_owned(),
retry: None, rate_limit: None,
transport: TransportSecurity::AllowInsecureHttp,
tls_roots: TlsRootConfig::default(),
otel: false,
buffer_capacity: 64,
redirect: RedirectConfig::default(),
pool_idle_timeout: None, pool_max_idle_per_host: 1,
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn test_retry_trigger_constants() {
assert_eq!(RetryTrigger::TOO_MANY_REQUESTS, RetryTrigger::Status(429));
assert_eq!(RetryTrigger::REQUEST_TIMEOUT, RetryTrigger::Status(408));
assert_eq!(
RetryTrigger::INTERNAL_SERVER_ERROR,
RetryTrigger::Status(500)
);
assert_eq!(RetryTrigger::BAD_GATEWAY, RetryTrigger::Status(502));
assert_eq!(RetryTrigger::SERVICE_UNAVAILABLE, RetryTrigger::Status(503));
assert_eq!(RetryTrigger::GATEWAY_TIMEOUT, RetryTrigger::Status(504));
}
#[test]
fn test_is_idempotent_method() {
assert!(is_idempotent_method(&http::Method::GET));
assert!(is_idempotent_method(&http::Method::HEAD));
assert!(is_idempotent_method(&http::Method::PUT));
assert!(is_idempotent_method(&http::Method::DELETE));
assert!(is_idempotent_method(&http::Method::OPTIONS));
assert!(is_idempotent_method(&http::Method::TRACE));
assert!(!is_idempotent_method(&http::Method::POST));
assert!(!is_idempotent_method(&http::Method::PATCH));
}
#[test]
fn test_retry_config_defaults() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.backoff.initial, Duration::from_millis(100));
assert_eq!(config.backoff.max, Duration::from_secs(10));
assert!((config.backoff.multiplier - 2.0).abs() < f64::EPSILON);
assert!(config.backoff.jitter);
assert!(
config
.always_retry
.contains(&RetryTrigger::TOO_MANY_REQUESTS)
);
assert_eq!(config.always_retry.len(), 1);
assert!(
config
.idempotent_retry
.contains(&RetryTrigger::TransportError)
);
assert!(config.idempotent_retry.contains(&RetryTrigger::Timeout));
assert!(
config
.idempotent_retry
.contains(&RetryTrigger::REQUEST_TIMEOUT)
);
assert!(
config
.idempotent_retry
.contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
);
assert!(config.idempotent_retry.contains(&RetryTrigger::BAD_GATEWAY));
assert!(
config
.idempotent_retry
.contains(&RetryTrigger::SERVICE_UNAVAILABLE)
);
assert!(
config
.idempotent_retry
.contains(&RetryTrigger::GATEWAY_TIMEOUT)
);
assert_eq!(config.idempotent_retry.len(), 7);
assert!(!config.ignore_retry_after);
assert_eq!(
config.retry_response_drain_limit,
DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT
);
assert_eq!(
config.idempotency_key_header,
Some(http::header::HeaderName::from_static(
IDEMPOTENCY_KEY_HEADER_LOWER
))
);
}
#[test]
fn test_retry_config_disabled() {
let config = RetryConfig::disabled();
assert_eq!(config.max_retries, 0);
}
#[test]
fn test_retry_config_aggressive() {
let config = RetryConfig::aggressive();
assert_eq!(config.max_retries, 5);
assert_eq!(config.backoff.initial, Duration::from_millis(50));
assert_eq!(config.backoff.max, Duration::from_secs(30));
assert!(
config
.always_retry
.contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
);
assert!(config.idempotent_retry.is_empty());
}
#[test]
fn test_should_retry_always() {
let config = RetryConfig::default();
assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::GET, false));
assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, false));
assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, true));
}
#[test]
fn test_should_retry_idempotent_only() {
let config = RetryConfig::default();
assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::GET, false));
assert!(!config.should_retry(RetryTrigger::TransportError, &http::Method::POST, false));
assert!(config.should_retry(
RetryTrigger::INTERNAL_SERVER_ERROR,
&http::Method::GET,
false
));
assert!(!config.should_retry(
RetryTrigger::INTERNAL_SERVER_ERROR,
&http::Method::POST,
false
));
assert!(config.should_retry(
RetryTrigger::SERVICE_UNAVAILABLE,
&http::Method::HEAD,
false
));
assert!(!config.should_retry(
RetryTrigger::SERVICE_UNAVAILABLE,
&http::Method::POST,
false
));
assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::GET, false));
assert!(!config.should_retry(RetryTrigger::Timeout, &http::Method::POST, false));
}
#[test]
fn test_should_retry_with_idempotency_key() {
let config = RetryConfig::default();
assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::POST, true));
assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PUT, true));
assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::DELETE, true));
assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PATCH, true));
assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::POST, true));
assert!(config.should_retry(
RetryTrigger::INTERNAL_SERVER_ERROR,
&http::Method::POST,
true
));
}
#[test]
fn test_should_retry_not_configured() {
let config = RetryConfig::default();
assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::GET, false));
assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, false));
assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, true));
assert!(!config.should_retry(RetryTrigger::Status(404), &http::Method::GET, false));
}
#[test]
fn test_rate_limit_config_defaults() {
let config = RateLimitConfig::default();
assert_eq!(config.max_concurrent_requests, 100);
}
#[test]
fn test_rate_limit_config_unlimited() {
let config = RateLimitConfig::unlimited();
assert_eq!(config.max_concurrent_requests, usize::MAX);
}
#[test]
fn test_rate_limit_config_conservative() {
let config = RateLimitConfig::conservative();
assert_eq!(config.max_concurrent_requests, 10);
}
#[test]
fn test_http_client_config_defaults() {
let config = HttpClientConfig::default();
assert_eq!(config.request_timeout, Duration::from_secs(30));
assert_eq!(config.max_body_size, 10 * 1024 * 1024);
assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
assert!(config.retry.is_some());
assert!(config.rate_limit.is_some());
assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
assert!(!config.otel);
assert_eq!(config.buffer_capacity, 1024);
}
#[test]
fn test_http_client_config_minimal() {
let config = HttpClientConfig::minimal();
assert_eq!(config.request_timeout, Duration::from_secs(10));
assert_eq!(config.max_body_size, 1024 * 1024);
assert!(config.retry.is_none());
assert!(config.rate_limit.is_none());
}
#[test]
fn test_http_client_config_infra_default() {
let config = HttpClientConfig::infra_default();
assert_eq!(config.request_timeout, Duration::from_mins(1));
assert_eq!(config.max_body_size, 50 * 1024 * 1024);
assert!(config.retry.is_some());
assert_eq!(config.retry.unwrap().max_retries, 5);
}
#[test]
fn test_http_client_config_token_endpoint() {
let config = HttpClientConfig::token_endpoint();
assert_eq!(config.request_timeout, Duration::from_secs(30));
let retry = config.retry.unwrap();
assert!(retry.idempotent_retry.is_empty());
assert!(retry.always_retry.contains(&RetryTrigger::TransportError));
assert!(
retry
.always_retry
.contains(&RetryTrigger::TOO_MANY_REQUESTS)
);
let rate_limit = config.rate_limit.unwrap();
assert_eq!(rate_limit.max_concurrent_requests, 10); }
#[test]
fn test_http_client_config_for_testing() {
let config = HttpClientConfig::for_testing();
assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
assert!(config.retry.is_none());
}
#[test]
fn test_http_client_config_sse() {
let config = HttpClientConfig::sse();
assert_eq!(config.request_timeout, Duration::from_hours(24));
assert!(config.total_timeout.is_none());
assert!(config.retry.is_none());
assert!(config.rate_limit.is_none());
assert!(!config.otel);
assert_eq!(config.buffer_capacity, 64);
assert!(config.pool_idle_timeout.is_none());
assert_eq!(config.pool_max_idle_per_host, 1);
}
}