use serde::{Deserialize, Serialize};
fn configured_true() -> bool {
true
}
pub const DEFAULT_MAX_RETRIES: u32 = 3;
pub const MAX_EFFECTIVE_RETRIES: u32 = 15;
pub const DEFAULT_RETRY_BODY_LIMIT_BYTES: u64 = 65_536;
pub const MAX_RETRY_BODY_LIMIT_BYTES: u64 = 65_536;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "u16")]
pub struct HttpStatusCode(u16);
impl HttpStatusCode {
#[must_use]
pub const fn get(self) -> u16 {
self.0
}
}
impl TryFrom<u16> for HttpStatusCode {
type Error = String;
fn try_from(value: u16) -> Result<Self, Self::Error> {
if (100..=599).contains(&value) {
Ok(Self(value))
} else {
Err(format!("http status code must be in 100..=599, got {value}"))
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "u64")]
pub struct RetryBodyLimit(u64);
impl RetryBodyLimit {
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
#[must_use]
pub const fn default_limit() -> Self {
Self(DEFAULT_RETRY_BODY_LIMIT_BYTES)
}
}
impl Default for RetryBodyLimit {
fn default() -> Self {
Self::default_limit()
}
}
impl TryFrom<u64> for RetryBodyLimit {
type Error = String;
fn try_from(value: u64) -> Result<Self, Self::Error> {
if value > MAX_RETRY_BODY_LIMIT_BYTES {
Err(format!(
"retry_body_limit_bytes must be <= {MAX_RETRY_BODY_LIMIT_BYTES} (64 KiB, the replay buffer cap), got {value}"
))
} else {
Ok(Self(value))
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
#[serde(try_from = "f64")]
pub struct BudgetPercent(f64);
impl BudgetPercent {
#[must_use]
pub const fn get(self) -> f64 {
self.0
}
}
impl TryFrom<f64> for BudgetPercent {
type Error = String;
fn try_from(value: f64) -> Result<Self, Self::Error> {
if !value.is_finite() {
return Err(format!("retry_budget.percent must be finite, got {value}"));
}
if !(0.0..=100.0).contains(&value) {
return Err(format!("retry_budget.percent must be in 0.0..=100.0, got {value}"));
}
Ok(Self(value))
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RetriableCondition {
ConnectFailure,
Reset,
RefusedStream,
#[serde(alias = "status_5xx")]
Status5xx,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, try_from = "RawBackoffConfig")]
pub struct BackoffConfig {
pub base_interval_ms: u64,
pub max_interval_ms: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBackoffConfig {
base_interval_ms: u64,
max_interval_ms: u64,
}
impl TryFrom<RawBackoffConfig> for BackoffConfig {
type Error = String;
fn try_from(raw: RawBackoffConfig) -> Result<Self, Self::Error> {
if raw.base_interval_ms == 0 {
return Err("backoff.base_interval_ms must be > 0".into());
}
if raw.max_interval_ms < raw.base_interval_ms {
return Err(format!(
"backoff.max_interval_ms ({}) must be >= base_interval_ms ({})",
raw.max_interval_ms, raw.base_interval_ms
));
}
Ok(Self {
base_interval_ms: raw.base_interval_ms,
max_interval_ms: raw.max_interval_ms,
})
}
}
impl Default for BackoffConfig {
fn default() -> Self {
Self {
base_interval_ms: 25,
max_interval_ms: 250,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RetryBudgetConfig {
pub percent: BudgetPercent,
#[serde(default = "default_min_retries_per_second")]
pub min_retries_per_second: u32,
}
fn default_min_retries_per_second() -> u32 {
10
}
impl Default for RetryBudgetConfig {
fn default() -> Self {
Self {
percent: BudgetPercent(20.0),
min_retries_per_second: default_min_retries_per_second(),
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_retries: Option<u32>,
#[serde(default)]
pub retriable_status_codes: Vec<HttpStatusCode>,
#[serde(default)]
pub retriable_conditions: Vec<RetriableCondition>,
#[serde(default)]
pub per_try_timeout_ms: Option<u64>,
#[serde(default)]
pub request_timeout_ms: Option<u64>,
#[serde(default)]
pub backoff: Option<BackoffConfig>,
#[serde(default = "configured_true", skip_deserializing, skip_serializing)]
pub configured: bool,
#[serde(default)]
pub retry_budget: Option<RetryBudgetConfig>,
#[serde(default)]
pub retry_body_limit_bytes: Option<RetryBodyLimit>,
#[serde(default)]
pub allow_non_idempotent: Option<bool>,
}
impl RetryPolicy {
#[must_use]
pub fn legacy_default() -> Self {
Self {
max_retries: Some(DEFAULT_MAX_RETRIES),
retriable_status_codes: Vec::new(),
retriable_conditions: vec![RetriableCondition::ConnectFailure],
per_try_timeout_ms: None,
request_timeout_ms: None,
backoff: None,
configured: false,
retry_budget: None,
retry_body_limit_bytes: Some(RetryBodyLimit::default_limit()),
allow_non_idempotent: None,
}
}
#[must_use]
pub fn effective_max_retries(&self) -> u32 {
self.max_retries
.unwrap_or(DEFAULT_MAX_RETRIES)
.min(MAX_EFFECTIVE_RETRIES)
}
#[must_use]
pub fn body_limit_bytes(&self) -> u64 {
self.retry_body_limit_bytes
.unwrap_or_else(RetryBodyLimit::default_limit)
.get()
}
#[must_use]
pub fn allow_non_idempotent(&self) -> bool {
self.allow_non_idempotent.unwrap_or(false)
}
pub fn validate_timeout_bounds(&self, context: &str) -> Result<(), String> {
for (field, value) in [
("retry_policy.per_try_timeout_ms", self.per_try_timeout_ms),
("retry_policy.request_timeout_ms", self.request_timeout_ms),
] {
if let Some(0) = value {
return Err(format!("{context}: {field} is 0 (must be > 0)"));
}
if let Some(timeout_ms) = value
&& timeout_ms > super::super::validate::cluster::MAX_TIMEOUT_MS
{
return Err(format!(
"{context}: {field} ({timeout_ms} ms) exceeds maximum ({} ms / 1 hour)",
super::super::validate::cluster::MAX_TIMEOUT_MS
));
}
}
if let Some(budget) = &self.retry_budget
&& budget.min_retries_per_second == 0
{
return Err(format!(
"{context}: retry_budget.min_retries_per_second is 0 (must be > 0; \
0 stops the budget refilling and denies every retry)"
));
}
Ok(())
}
#[must_use]
pub fn merge_override(&self, route: &Self) -> Self {
Self {
configured: self.configured || route.configured,
max_retries: route.max_retries.or(self.max_retries),
retriable_status_codes: if route.retriable_status_codes.is_empty() {
self.retriable_status_codes.clone()
} else {
route.retriable_status_codes.clone()
},
retriable_conditions: if route.retriable_conditions.is_empty() {
self.retriable_conditions.clone()
} else {
route.retriable_conditions.clone()
},
per_try_timeout_ms: route.per_try_timeout_ms.or(self.per_try_timeout_ms),
request_timeout_ms: route.request_timeout_ms.or(self.request_timeout_ms),
backoff: route.backoff.clone().or_else(|| self.backoff.clone()),
retry_budget: route.retry_budget.clone().or_else(|| self.retry_budget.clone()),
retry_body_limit_bytes: route.retry_body_limit_bytes.or(self.retry_body_limit_bytes),
allow_non_idempotent: route.allow_non_idempotent.or(self.allow_non_idempotent),
}
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self::legacy_default()
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp, reason = "tests")]
mod tests {
use super::*;
#[test]
fn parse_full_policy() {
let yaml = "
max_retries: 3
retriable_status_codes: [502, 503, 504]
retriable_conditions:
- connect_failure
- reset
- refused_stream
- status5xx
per_try_timeout_ms: 2000
request_timeout_ms: 10000
backoff:
base_interval_ms: 25
max_interval_ms: 250
retry_budget:
percent: 20
min_retries_per_second: 10
retry_body_limit_bytes: 65536
allow_non_idempotent: true
";
let policy: RetryPolicy = serde_yaml::from_str(yaml).unwrap();
assert_eq!(policy.effective_max_retries(), 3);
assert_eq!(policy.retriable_status_codes.len(), 3);
assert_eq!(policy.retriable_status_codes.get(1).map(|code| code.get()), Some(503));
assert_eq!(policy.retriable_conditions.len(), 4);
assert_eq!(policy.per_try_timeout_ms, Some(2000));
assert_eq!(policy.request_timeout_ms, Some(10_000));
assert!(policy.allow_non_idempotent());
assert_eq!(policy.body_limit_bytes(), 65_536);
}
#[test]
fn reject_invalid_status_code() {
let yaml = "retriable_status_codes: [99]";
let err = serde_yaml::from_str::<RetryPolicy>(yaml).unwrap_err();
assert!(err.to_string().contains("100..=599"), "got: {err}");
}
#[test]
fn reject_body_limit_above_cap() {
let yaml = "retry_body_limit_bytes: 20000000";
let err = serde_yaml::from_str::<RetryPolicy>(yaml).unwrap_err();
assert!(err.to_string().contains("64 KiB"), "got: {err}");
}
#[test]
fn reject_invalid_budget_percent() {
let yaml = "
retry_budget:
percent: 150
min_retries_per_second: 1
";
let err = serde_yaml::from_str::<RetryPolicy>(yaml).unwrap_err();
assert!(err.to_string().contains("0.0..=100.0"), "got: {err}");
}
#[test]
fn reject_zero_backoff_base() {
let yaml = "
backoff:
base_interval_ms: 0
max_interval_ms: 100
";
let err = serde_yaml::from_str::<RetryPolicy>(yaml).unwrap_err();
assert!(err.to_string().contains("base_interval_ms"), "got: {err}");
}
#[test]
fn reject_backoff_max_less_than_base() {
let yaml = "
backoff:
base_interval_ms: 100
max_interval_ms: 50
";
let err = serde_yaml::from_str::<RetryPolicy>(yaml).unwrap_err();
assert!(err.to_string().contains("max_interval_ms"), "got: {err}");
}
#[test]
fn merge_override_replaces_lists() {
let cluster = RetryPolicy {
retriable_status_codes: vec![HttpStatusCode(502)],
retriable_conditions: vec![RetriableCondition::ConnectFailure],
max_retries: Some(3),
..RetryPolicy::legacy_default()
};
let route = RetryPolicy {
retriable_status_codes: vec![HttpStatusCode(503)],
retriable_conditions: vec![RetriableCondition::Status5xx],
max_retries: Some(2),
allow_non_idempotent: Some(true),
..RetryPolicy {
configured: true,
max_retries: None,
retriable_status_codes: Vec::new(),
retriable_conditions: Vec::new(),
per_try_timeout_ms: None,
request_timeout_ms: None,
backoff: None,
retry_budget: None,
retry_body_limit_bytes: None,
allow_non_idempotent: None,
}
};
let merged = cluster.merge_override(&route);
assert_eq!(merged.effective_max_retries(), 2);
assert_eq!(merged.retriable_status_codes, vec![HttpStatusCode(503)]);
assert_eq!(merged.retriable_conditions, vec![RetriableCondition::Status5xx]);
assert!(merged.allow_non_idempotent());
}
#[test]
fn merge_partial_route_preserves_cluster_max_retries() {
let cluster = RetryPolicy {
max_retries: Some(5),
..RetryPolicy::legacy_default()
};
let route = RetryPolicy {
configured: true,
allow_non_idempotent: Some(true),
max_retries: None,
retriable_status_codes: Vec::new(),
retriable_conditions: Vec::new(),
per_try_timeout_ms: None,
request_timeout_ms: None,
backoff: None,
retry_budget: None,
retry_body_limit_bytes: None,
};
let merged = cluster.merge_override(&route);
assert_eq!(merged.effective_max_retries(), 5);
assert!(merged.allow_non_idempotent());
}
#[test]
fn legacy_default_is_connect_failure_only() {
let policy = RetryPolicy::legacy_default();
assert_eq!(policy.effective_max_retries(), 3);
assert_eq!(policy.retriable_conditions, vec![RetriableCondition::ConnectFailure]);
assert!(policy.retriable_status_codes.is_empty());
assert!(!policy.allow_non_idempotent());
assert_eq!(policy.body_limit_bytes(), 65_536);
}
#[test]
fn effective_max_retries_clamps_to_pingora_cap() {
let policy = RetryPolicy {
max_retries: Some(100),
..RetryPolicy::legacy_default()
};
assert_eq!(
policy.effective_max_retries(),
MAX_EFFECTIVE_RETRIES,
"values beyond Pingora's per-request attempt cap are clamped"
);
}
#[test]
fn validate_rejects_zero_min_retries_per_second() {
let budget: RetryBudgetConfig = serde_yaml::from_str("percent: 20\nmin_retries_per_second: 0").unwrap();
let policy = RetryPolicy {
retry_budget: Some(budget),
..RetryPolicy::legacy_default()
};
let err = policy
.validate_timeout_bounds("cluster 'backend'")
.expect_err("min_retries_per_second of 0 permanently empties the budget and must be rejected");
assert!(
err.contains("min_retries_per_second"),
"error should name the offending field: {err}"
);
}
#[test]
fn validate_accepts_nonzero_min_retries_per_second() {
let budget: RetryBudgetConfig = serde_yaml::from_str("percent: 20\nmin_retries_per_second: 5").unwrap();
let policy = RetryPolicy {
retry_budget: Some(budget),
..RetryPolicy::legacy_default()
};
assert!(
policy.validate_timeout_bounds("cluster 'backend'").is_ok(),
"a positive min_retries_per_second should pass validation"
);
}
#[test]
fn configured_flag_distinguishes_operator_policies() {
assert!(
!RetryPolicy::legacy_default().configured,
"the built-in default is not operator-configured"
);
let parsed: RetryPolicy = serde_yaml::from_str("max_retries: 2").unwrap();
assert!(parsed.configured, "any deserialized policy is operator-configured");
let merged = RetryPolicy::legacy_default().merge_override(&parsed);
assert!(merged.configured, "merging in a configured override keeps the flag");
}
}