use std::{cmp::min, time::Duration};
pub struct Retry {
pub consecutive: u32,
pub backoff: Duration,
}
pub struct FailureTracker {
consecutive: u32,
max: Option<u32>,
backoff_unit: Duration,
max_backoff_exp: u32,
}
impl FailureTracker {
pub fn new(max: Option<u32>, backoff_unit: Duration, max_backoff_exp: u32) -> Self {
FailureTracker { consecutive: 0, max, backoff_unit, max_backoff_exp }
}
pub fn record_success(&mut self) -> u32 {
std::mem::take(&mut self.consecutive)
}
pub fn record_failure(&mut self) -> Result<Retry, u32> {
let preceding = self.consecutive;
self.consecutive = preceding.saturating_add(1);
if self
.max
.is_some_and(|max| self.consecutive >= max)
{
Err(self.consecutive)
} else {
Ok(Retry {
consecutive: self.consecutive,
backoff: self
.backoff_unit
.saturating_mul(2_u32.saturating_pow(min(preceding, self.max_backoff_exp))),
})
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::limit_reached_at_max(Some(3), 3, true)]
#[case::below_limit(Some(3), 2, false)]
fn failure_tracker_limit(
#[case] max: Option<u32>,
#[case] failures: u32,
#[case] expect_reached: bool,
) {
let mut tracker = FailureTracker::new(max, Duration::from_secs(1), 5);
let mut recorded = Err(0);
for _ in 0..failures {
recorded = tracker.record_failure();
}
assert_eq!(recorded.is_err(), expect_reached);
assert_eq!(
recorded.map_or_else(|consecutive| consecutive, |retry| retry.consecutive),
failures
);
}
#[rstest]
#[case::first_failure_waits_one_unit(1, Duration::from_secs(1))]
#[case::doubles_per_further_failure(4, Duration::from_secs(8))]
#[case::capped_at_max_exp(10, Duration::from_secs(32))]
fn failure_tracker_backoff(#[case] failures: u32, #[case] expected: Duration) {
let mut tracker = FailureTracker::new(None, Duration::from_secs(1), 5);
let mut backoff = Duration::ZERO;
for _ in 0..failures {
backoff = tracker
.record_failure()
.expect("no limit to reach")
.backoff;
}
assert_eq!(backoff, expected);
}
}