use std::collections::BTreeSet;
use crate::status::Retryability;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum FailureCategory {
Transient,
Permanent,
Cancelled,
Deadline,
Authentication,
Authorization,
Validation,
ResourceExhausted,
Dependency,
Transport,
Engine,
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryDecision {
Permitted,
Denied(RetryDenialReason),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryDenialReason {
InvalidAttemptNumber,
NonRetryable,
CategoryNotAllowed,
MaximumRetriesExceeded,
MaximumAttemptsExceeded,
UnknownOutcome,
Cancelled,
Deadline,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryPolicyError {
InvalidMaxAttempts,
}
impl std::fmt::Display for RetryPolicyError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidMaxAttempts => {
formatter.write_str("maximum attempts must be greater than zero")
}
}
}
}
impl std::error::Error for RetryPolicyError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RetryPolicy {
max_retries: u32,
max_attempts: u32,
retryable_categories: BTreeSet<FailureCategory>,
}
impl RetryPolicy {
pub fn new(max_retries: u32, max_attempts: u32) -> Result<Self, RetryPolicyError> {
if max_attempts == 0 {
return Err(RetryPolicyError::InvalidMaxAttempts);
}
Ok(Self {
max_retries,
max_attempts,
retryable_categories: BTreeSet::new(),
})
}
pub fn with_retryable_category(mut self, category: FailureCategory) -> Self {
self.retryable_categories.insert(category);
self
}
pub fn with_retryable_categories<I>(mut self, categories: I) -> Self
where
I: IntoIterator<Item = FailureCategory>,
{
self.retryable_categories.extend(categories);
self
}
pub const fn max_retries(&self) -> u32 {
self.max_retries
}
pub const fn max_attempts(&self) -> u32 {
self.max_attempts
}
pub const fn effective_max_attempts(&self) -> u32 {
let retry_limit = self.max_retries.saturating_add(1);
if self.max_attempts < retry_limit {
self.max_attempts
} else {
retry_limit
}
}
pub fn retryable_categories(&self) -> &BTreeSet<FailureCategory> {
&self.retryable_categories
}
pub fn allows_category(&self, category: FailureCategory) -> bool {
self.retryable_categories.contains(&category)
}
pub fn constrain_with(&self, constraint: &Self) -> Self {
let retryable_categories = self
.retryable_categories
.intersection(&constraint.retryable_categories)
.copied()
.collect();
Self {
max_retries: self.max_retries.min(constraint.max_retries),
max_attempts: self.max_attempts.min(constraint.max_attempts),
retryable_categories,
}
}
pub fn evaluate(
&self,
category: FailureCategory,
retryability: Retryability,
attempt_number: u32,
) -> RetryDecision {
if attempt_number == 0 {
return RetryDecision::Denied(RetryDenialReason::InvalidAttemptNumber);
}
match category {
FailureCategory::Unknown => {
return RetryDecision::Denied(RetryDenialReason::UnknownOutcome);
}
FailureCategory::Cancelled => {
return RetryDecision::Denied(RetryDenialReason::Cancelled);
}
FailureCategory::Deadline => {
return RetryDecision::Denied(RetryDenialReason::Deadline);
}
_ => {}
}
if retryability == Retryability::NonRetryable {
return RetryDecision::Denied(RetryDenialReason::NonRetryable);
}
if !self.allows_category(category) {
return RetryDecision::Denied(RetryDenialReason::CategoryNotAllowed);
}
if attempt_number >= self.max_attempts {
return RetryDecision::Denied(RetryDenialReason::MaximumAttemptsExceeded);
}
if attempt_number > self.max_retries {
return RetryDecision::Denied(RetryDenialReason::MaximumRetriesExceeded);
}
RetryDecision::Permitted
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryBudget {
limit: u32,
consumed: u32,
}
impl RetryBudget {
pub const fn new(limit: u32) -> Self {
Self { limit, consumed: 0 }
}
pub const fn limit(&self) -> u32 {
self.limit
}
pub const fn consumed(&self) -> u32 {
self.consumed
}
pub const fn remaining(&self) -> u32 {
self.limit - self.consumed
}
pub const fn is_exhausted(&self) -> bool {
self.consumed >= self.limit
}
pub fn try_consume(&mut self) -> Result<(), RetryBudgetError> {
if self.is_exhausted() {
return Err(RetryBudgetError::Exhausted);
}
self.consumed += 1;
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryBudgetError {
Exhausted,
}
impl std::fmt::Display for RetryBudgetError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exhausted => formatter.write_str("retry budget exhausted"),
}
}
}
impl std::error::Error for RetryBudgetError {}
#[cfg(test)]
mod tests {
use super::*;
fn retryable_policy(max_retries: u32, max_attempts: u32) -> RetryPolicy {
RetryPolicy::new(max_retries, max_attempts)
.unwrap()
.with_retryable_categories([
FailureCategory::Transient,
FailureCategory::Dependency,
FailureCategory::Transport,
])
}
#[test]
fn retry_policy_rejects_zero_max_attempts() {
assert_eq!(
RetryPolicy::new(0, 0),
Err(RetryPolicyError::InvalidMaxAttempts)
);
}
#[test]
fn retry_policy_starts_without_retryable_categories() {
let policy = RetryPolicy::new(3, 4).unwrap();
assert_eq!(policy.max_retries(), 3);
assert_eq!(policy.max_attempts(), 4);
assert_eq!(policy.effective_max_attempts(), 4);
assert!(policy.retryable_categories().is_empty());
}
#[test]
fn retryable_categories_can_be_added() {
let policy = RetryPolicy::new(3, 4)
.unwrap()
.with_retryable_category(FailureCategory::Transient)
.with_retryable_category(FailureCategory::Transport);
assert!(policy.allows_category(FailureCategory::Transient));
assert!(policy.allows_category(FailureCategory::Transport));
assert!(!policy.allows_category(FailureCategory::Validation));
}
#[test]
fn retry_is_permitted_for_allowed_retryable_category() {
let policy = retryable_policy(2, 3);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 1,),
RetryDecision::Permitted
);
}
#[test]
fn non_retryable_metadata_overrides_allowed_category() {
let policy = retryable_policy(2, 3);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::NonRetryable, 1,),
RetryDecision::Denied(RetryDenialReason::NonRetryable)
);
}
#[test]
fn disallowed_category_is_rejected() {
let policy = retryable_policy(2, 3);
assert_eq!(
policy.evaluate(FailureCategory::Validation, Retryability::Retryable, 1,),
RetryDecision::Denied(RetryDenialReason::CategoryNotAllowed)
);
}
#[test]
fn unknown_outcome_is_rejected() {
let policy = RetryPolicy::new(3, 4)
.unwrap()
.with_retryable_category(FailureCategory::Unknown);
assert_eq!(
policy.evaluate(FailureCategory::Unknown, Retryability::Retryable, 1),
RetryDecision::Denied(RetryDenialReason::UnknownOutcome)
);
}
#[test]
fn cancellation_and_deadline_are_rejected() {
let policy = retryable_policy(3, 4);
assert_eq!(
policy.evaluate(FailureCategory::Cancelled, Retryability::Retryable, 1,),
RetryDecision::Denied(RetryDenialReason::Cancelled)
);
assert_eq!(
policy.evaluate(FailureCategory::Deadline, Retryability::Retryable, 1,),
RetryDecision::Denied(RetryDenialReason::Deadline)
);
}
#[test]
fn maximum_retries_is_enforced() {
let policy = retryable_policy(1, 10);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 1,),
RetryDecision::Permitted
);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 2,),
RetryDecision::Denied(RetryDenialReason::MaximumRetriesExceeded)
);
}
#[test]
fn maximum_attempts_is_enforced() {
let policy = retryable_policy(10, 2);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 1,),
RetryDecision::Permitted
);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 2,),
RetryDecision::Denied(RetryDenialReason::MaximumAttemptsExceeded)
);
}
#[test]
fn effective_attempt_limit_uses_the_strictest_bound() {
let retries_stricter = RetryPolicy::new(2, 10).unwrap();
assert_eq!(retries_stricter.effective_max_attempts(), 3);
let attempts_stricter = RetryPolicy::new(10, 2).unwrap();
assert_eq!(attempts_stricter.effective_max_attempts(), 2);
}
#[test]
fn combining_policies_is_conservative() {
let operation = RetryPolicy::new(5, 6)
.unwrap()
.with_retryable_categories([FailureCategory::Transient, FailureCategory::Dependency]);
let capability = RetryPolicy::new(1, 2)
.unwrap()
.with_retryable_categories([FailureCategory::Transient, FailureCategory::Transport]);
let effective = operation.constrain_with(&capability);
assert_eq!(effective.max_retries(), 1);
assert_eq!(effective.max_attempts(), 2);
assert_eq!(
effective
.retryable_categories()
.iter()
.copied()
.collect::<Vec<_>>(),
vec![FailureCategory::Transient]
);
assert!(effective.allows_category(FailureCategory::Transient));
assert!(!effective.allows_category(FailureCategory::Dependency));
assert!(!effective.allows_category(FailureCategory::Transport));
}
#[test]
fn empty_category_intersection_forbids_retry() {
let first = RetryPolicy::new(2, 3)
.unwrap()
.with_retryable_category(FailureCategory::Transient);
let second = RetryPolicy::new(2, 3)
.unwrap()
.with_retryable_category(FailureCategory::Transport);
let effective = first.constrain_with(&second);
assert!(effective.retryable_categories().is_empty());
assert_eq!(
effective.evaluate(FailureCategory::Transient, Retryability::Retryable, 1,),
RetryDecision::Denied(RetryDenialReason::CategoryNotAllowed)
);
}
#[test]
fn invalid_zero_attempt_number_is_rejected() {
let policy = retryable_policy(2, 3);
assert_eq!(
policy.evaluate(FailureCategory::Transient, Retryability::Retryable, 0,),
RetryDecision::Denied(RetryDenialReason::InvalidAttemptNumber)
);
}
#[test]
fn maximum_retry_count_does_not_overflow() {
let policy = RetryPolicy::new(u32::MAX, u32::MAX).unwrap();
assert_eq!(policy.effective_max_attempts(), u32::MAX);
}
#[test]
fn retry_budget_tracks_consumption() {
let mut budget = RetryBudget::new(2);
assert_eq!(budget.limit(), 2);
assert_eq!(budget.consumed(), 0);
assert_eq!(budget.remaining(), 2);
assert!(!budget.is_exhausted());
budget.try_consume().unwrap();
assert_eq!(budget.consumed(), 1);
assert_eq!(budget.remaining(), 1);
budget.try_consume().unwrap();
assert_eq!(budget.consumed(), 2);
assert_eq!(budget.remaining(), 0);
assert!(budget.is_exhausted());
}
#[test]
fn retry_budget_rejects_consumption_after_exhaustion() {
let mut budget = RetryBudget::new(1);
budget.try_consume().unwrap();
assert_eq!(budget.try_consume(), Err(RetryBudgetError::Exhausted));
assert_eq!(budget.consumed(), 1);
assert_eq!(budget.remaining(), 0);
}
#[test]
fn zero_retry_budget_is_immediately_exhausted() {
let mut budget = RetryBudget::new(0);
assert!(budget.is_exhausted());
assert_eq!(budget.remaining(), 0);
assert_eq!(budget.try_consume(), Err(RetryBudgetError::Exhausted));
}
}