use std::{
fmt,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use super::error::BspError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recovery {
Retry { after: Option<Duration> },
Fatal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub jitter: f64,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(8),
jitter: 0.25,
}
}
}
impl RetryPolicy {
#[must_use]
pub const fn none() -> Self {
Self {
max_retries: 0,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(8),
jitter: 0.25,
}
}
#[must_use]
pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
#[must_use]
pub const fn with_base_delay(mut self, base_delay: Duration) -> Self {
self.base_delay = base_delay;
self
}
#[must_use]
pub const fn with_max_delay(mut self, max_delay: Duration) -> Self {
self.max_delay = max_delay;
self
}
#[must_use]
pub const fn with_jitter(mut self, jitter: f64) -> Self {
self.jitter = jitter;
self
}
#[must_use]
pub fn backoff(&self, attempt: u32) -> Duration {
let exp = self.base_delay.saturating_mul(1 << attempt.min(6));
let capped = exp.min(self.max_delay);
if self.jitter <= 0.0 {
return capped;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let r = f64::from(nanos % 1000) / 1000.0; let factor = (1.0 + self.jitter * (r * 2.0 - 1.0)).max(0.05);
capped.mul_f64(factor)
}
}
pub type ClassifyFn = Arc<dyn Fn(&BspError) -> Recovery + Send + Sync>;
#[derive(Clone)]
pub enum RecoveryPolicy {
FailFast,
Retry {
policy: RetryPolicy,
classify: ClassifyFn,
},
RouteToErrorEdge,
}
impl RecoveryPolicy {
#[must_use]
pub fn retry(
policy: RetryPolicy,
classify: impl Fn(&BspError) -> Recovery + Send + Sync + 'static,
) -> Self {
Self::Retry {
policy,
classify: Arc::new(classify),
}
}
}
impl fmt::Debug for RecoveryPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FailFast => f.write_str("FailFast"),
Self::Retry { policy, .. } => f.debug_tuple("Retry").field(policy).finish(),
Self::RouteToErrorEdge => f.write_str("RouteToErrorEdge"),
}
}
}
pub(crate) async fn sleep(duration: Duration) {
#[cfg(feature = "tokio")]
{
tokio::time::sleep(duration).await;
}
#[cfg(not(feature = "tokio"))]
{
std::thread::sleep(duration);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policy_is_three_retries() {
let policy = RetryPolicy::default();
assert_eq!(policy.max_retries, 3);
assert_eq!(policy.base_delay, Duration::from_millis(500));
assert_eq!(policy.max_delay, Duration::from_secs(8));
assert!((policy.jitter - 0.25).abs() < f64::EPSILON);
}
#[test]
fn test_none_disables_retry() {
assert_eq!(RetryPolicy::none().max_retries, 0);
}
#[test]
fn test_const_builders() {
let policy = RetryPolicy::default()
.with_max_retries(5)
.with_base_delay(Duration::from_millis(10))
.with_max_delay(Duration::from_secs(1))
.with_jitter(0.0);
assert_eq!(policy.max_retries, 5);
assert_eq!(policy.base_delay, Duration::from_millis(10));
assert_eq!(policy.max_delay, Duration::from_secs(1));
assert!(policy.jitter.abs() < f64::EPSILON);
}
#[test]
fn test_backoff_grows_and_respects_bounds() {
let policy = RetryPolicy::default().with_jitter(0.0);
assert_eq!(policy.backoff(0), Duration::from_millis(500));
assert_eq!(policy.backoff(1), Duration::from_secs(1));
assert_eq!(policy.backoff(2), Duration::from_secs(2));
assert_eq!(policy.backoff(100), Duration::from_secs(8));
}
#[test]
fn test_backoff_jitter_stays_within_factor() {
let policy = RetryPolicy::default().with_jitter(0.25);
for attempt in 0..10 {
let d = policy.backoff(attempt);
let base = policy.clone().with_jitter(0.0).backoff(attempt);
let lo = base.mul_f64(0.74); let hi = base.mul_f64(1.26);
assert!(
d >= lo && d <= hi,
"attempt {attempt}: {d:?} outside [{lo:?}, {hi:?}]"
);
}
}
#[test]
fn test_recovery_policy_retry_constructor() {
let policy = RecoveryPolicy::retry(RetryPolicy::none(), |_| Recovery::Fatal);
let RecoveryPolicy::Retry { classify, .. } = policy else {
panic!("expected Retry variant");
};
assert_eq!(classify(&BspError::Deadlock), Recovery::Fatal);
}
}