use crate::error::FaucetError;
use crate::resilience::classify::{RetryClassSet, classify};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BackoffKind {
None,
Fixed,
#[default]
Exponential,
}
impl BackoffKind {
pub fn delay(self, base: Duration, max: Duration, attempt: u32) -> Duration {
match self {
BackoffKind::None => Duration::ZERO,
BackoffKind::Fixed => base.min(max),
BackoffKind::Exponential => base.saturating_mul(2u32.saturating_pow(attempt)).min(max),
}
}
}
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub backoff: BackoffKind,
pub base: Duration,
pub max: Duration,
pub jitter: bool,
pub retry_on: RetryClassSet,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
backoff: BackoffKind::Exponential,
base: Duration::from_millis(200),
max: Duration::from_secs(30),
jitter: true,
retry_on: RetryClassSet::default(),
}
}
}
impl RetryPolicy {
pub fn is_retriable(&self, err: &FaucetError) -> bool {
classify(err).is_some_and(|c| self.retry_on.contains(c))
}
}
#[derive(Debug, Clone, Copy)]
pub struct CircuitBreakerConfig {
pub consecutive_failures: u32,
pub cooldown: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PoisonAction {
#[default]
Dlq,
Drop,
Fail,
}
#[derive(Debug, Clone, Copy)]
pub struct PoisonPolicy {
pub max_row_attempts: u32,
pub action: PoisonAction,
}
#[derive(Debug, Clone, Default)]
pub struct ResiliencePolicy {
pub retry: RetryPolicy,
pub circuit_breaker: Option<CircuitBreakerConfig>,
pub poison: Option<PoisonPolicy>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resilience::classify::RetryClass;
#[test]
fn default_policy_retries_all_classes() {
let p = RetryPolicy::default();
assert_eq!(p.max_attempts, 5);
assert!(p.is_retriable(&FaucetError::HttpStatus {
status: 500,
url: "u".into(),
body: "".into()
}));
assert!(!p.is_retriable(&FaucetError::Auth("x".into())));
}
#[test]
fn restricted_retry_on_excludes_unlisted_classes() {
let p = RetryPolicy {
retry_on: RetryClassSet::from_iter([RetryClass::Http5xx]),
..RetryPolicy::default()
};
assert!(p.is_retriable(&FaucetError::HttpStatus {
status: 500,
url: "u".into(),
body: "".into()
}));
assert!(!p.is_retriable(&FaucetError::HttpStatus {
status: 429,
url: "u".into(),
body: "".into()
}));
}
#[test]
fn backoff_none_is_zero_fixed_is_constant_exp_grows() {
let base = Duration::from_millis(100);
let max = Duration::from_secs(10);
assert_eq!(BackoffKind::None.delay(base, max, 0), Duration::ZERO);
assert_eq!(BackoffKind::Fixed.delay(base, max, 3), base);
assert_eq!(
BackoffKind::Fixed.delay(Duration::from_secs(20), Duration::from_secs(10), 0),
Duration::from_secs(10)
);
assert_eq!(BackoffKind::Exponential.delay(base, max, 0), base);
assert_eq!(BackoffKind::Exponential.delay(base, max, 2), base * 4);
assert_eq!(BackoffKind::Exponential.delay(base, max, 30), max);
}
}