use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RestartLimiterConfig {
pub enabled: bool,
pub max_restarts: u32,
pub window_secs: u64,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
pub backoff_multiplier: f64,
}
impl Default for RestartLimiterConfig {
fn default() -> Self {
Self {
enabled: true,
max_restarts: 5,
window_secs: 60,
initial_backoff_ms: 100,
max_backoff_ms: 30_000,
backoff_multiplier: 2.0,
}
}
}
impl RestartLimiterConfig {
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
max_restarts: 0,
window_secs: 0,
initial_backoff_ms: 0,
max_backoff_ms: 0,
backoff_multiplier: 0.0,
}
}
#[must_use]
pub const fn window_duration(&self) -> Duration {
Duration::from_secs(self.window_secs)
}
#[must_use]
pub const fn initial_backoff(&self) -> Duration {
Duration::from_millis(self.initial_backoff_ms)
}
#[must_use]
pub const fn max_backoff(&self) -> Duration {
Duration::from_millis(self.max_backoff_ms)
}
}
#[derive(Debug)]
pub struct RestartLimiter {
config: RestartLimiterConfig,
restart_timestamps: Vec<Instant>,
consecutive_restarts: usize,
}
impl RestartLimiter {
#[must_use]
pub const fn new(config: RestartLimiterConfig) -> Self {
Self {
config,
restart_timestamps: Vec::new(),
consecutive_restarts: 0,
}
}
pub fn can_restart(&mut self) -> Result<(), RestartLimitExceeded> {
if !self.config.enabled {
return Ok(());
}
self.prune_expired();
if self.restart_timestamps.len() >= self.config.max_restarts as usize {
Err(RestartLimitExceeded {
attempts: self.restart_timestamps.len(),
max_restarts: self.config.max_restarts,
window_secs: self.config.window_secs,
})
} else {
Ok(())
}
}
pub fn record_restart(&mut self) -> Duration {
let now = Instant::now();
self.restart_timestamps.push(now);
#[allow(clippy::cast_precision_loss)]
let backoff_ms = self.config.initial_backoff_ms as f64
* self.config.backoff_multiplier.powi(
i32::try_from(self.consecutive_restarts).unwrap_or(i32::MAX),
);
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
let capped_backoff_ms =
(backoff_ms.min(self.config.max_backoff_ms as f64).max(0.0)) as u64;
self.consecutive_restarts += 1;
Duration::from_millis(capped_backoff_ms)
}
pub const fn reset_consecutive(&mut self) {
self.consecutive_restarts = 0;
}
#[must_use]
pub const fn stats(&self) -> RestartStats {
RestartStats {
restarts_in_window: self.restart_timestamps.len(),
consecutive_restarts: self.consecutive_restarts,
window_secs: self.config.window_secs,
max_restarts: self.config.max_restarts,
}
}
#[must_use]
pub const fn restarts_in_window(&self) -> usize {
self.restart_timestamps.len()
}
#[must_use]
pub const fn consecutive_restarts(&self) -> usize {
self.consecutive_restarts
}
#[must_use]
pub fn peek_backoff(&self) -> Duration {
#[allow(clippy::cast_precision_loss)]
let backoff_ms = self.config.initial_backoff_ms as f64
* self.config.backoff_multiplier.powi(
i32::try_from(self.consecutive_restarts).unwrap_or(i32::MAX),
);
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
let capped_backoff_ms =
(backoff_ms.min(self.config.max_backoff_ms as f64).max(0.0)) as u64;
Duration::from_millis(capped_backoff_ms)
}
fn prune_expired(&mut self) {
let now = Instant::now();
let window = Duration::from_secs(self.config.window_secs);
self.restart_timestamps
.retain(|&ts| now.duration_since(ts) < window);
}
}
impl Clone for RestartLimiter {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
restart_timestamps: Vec::new(), consecutive_restarts: 0, }
}
}
impl Default for RestartLimiter {
fn default() -> Self {
Self::new(RestartLimiterConfig::default())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestartLimitExceeded {
pub attempts: usize,
pub max_restarts: u32,
pub window_secs: u64,
}
impl std::fmt::Display for RestartLimitExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"restart limit exceeded: {} attempts (max {}) in {} seconds",
self.attempts, self.max_restarts, self.window_secs
)
}
}
impl std::error::Error for RestartLimitExceeded {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestartStats {
pub restarts_in_window: usize,
pub consecutive_restarts: usize,
pub window_secs: u64,
pub max_restarts: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_sensible_values() {
let config = RestartLimiterConfig::default();
assert!(config.enabled);
assert_eq!(config.max_restarts, 5);
assert_eq!(config.window_secs, 60);
assert_eq!(config.initial_backoff_ms, 100);
assert_eq!(config.max_backoff_ms, 30_000);
assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
}
#[test]
fn disabled_config_allows_all_restarts() {
let config = RestartLimiterConfig::disabled();
let mut limiter = RestartLimiter::new(config);
for _ in 0..100 {
assert!(limiter.can_restart().is_ok());
let _ = limiter.record_restart();
}
}
#[test]
fn limiter_allows_restarts_within_limit() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 3,
window_secs: 60,
initial_backoff_ms: 100,
max_backoff_ms: 1000,
backoff_multiplier: 2.0,
};
let mut limiter = RestartLimiter::new(config);
for _ in 0..3 {
assert!(limiter.can_restart().is_ok());
let _ = limiter.record_restart();
}
let result = limiter.can_restart();
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.attempts, 3);
assert_eq!(err.max_restarts, 3);
}
#[test]
fn backoff_grows_exponentially() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 10,
window_secs: 60,
initial_backoff_ms: 100,
max_backoff_ms: 10_000,
backoff_multiplier: 2.0,
};
let mut limiter = RestartLimiter::new(config);
let backoff1 = limiter.record_restart();
assert_eq!(backoff1, Duration::from_millis(100));
let backoff2 = limiter.record_restart();
assert_eq!(backoff2, Duration::from_millis(200));
let backoff3 = limiter.record_restart();
assert_eq!(backoff3, Duration::from_millis(400));
let backoff4 = limiter.record_restart();
assert_eq!(backoff4, Duration::from_millis(800));
}
#[test]
fn backoff_is_capped_at_max() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 20,
window_secs: 60,
initial_backoff_ms: 1000,
max_backoff_ms: 5000,
backoff_multiplier: 2.0,
};
let mut limiter = RestartLimiter::new(config);
let _ = limiter.record_restart();
let _ = limiter.record_restart();
let _ = limiter.record_restart();
let backoff4 = limiter.record_restart();
assert_eq!(backoff4, Duration::from_secs(5));
let backoff5 = limiter.record_restart();
assert_eq!(backoff5, Duration::from_secs(5));
}
#[test]
fn reset_consecutive_resets_backoff() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 10,
window_secs: 60,
initial_backoff_ms: 100,
max_backoff_ms: 10_000,
backoff_multiplier: 2.0,
};
let mut limiter = RestartLimiter::new(config);
let _ = limiter.record_restart(); let _ = limiter.record_restart(); let _ = limiter.record_restart();
limiter.reset_consecutive();
let backoff = limiter.record_restart();
assert_eq!(backoff, Duration::from_millis(100));
}
#[test]
fn stats_reflects_current_state() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 5,
window_secs: 60,
..Default::default()
};
let mut limiter = RestartLimiter::new(config);
let stats = limiter.stats();
assert_eq!(stats.restarts_in_window, 0);
assert_eq!(stats.consecutive_restarts, 0);
assert_eq!(stats.window_secs, 60);
assert_eq!(stats.max_restarts, 5);
let _ = limiter.record_restart();
let _ = limiter.record_restart();
let stats = limiter.stats();
assert_eq!(stats.restarts_in_window, 2);
assert_eq!(stats.consecutive_restarts, 2);
}
#[test]
fn peek_backoff_does_not_modify_state() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 10,
window_secs: 60,
initial_backoff_ms: 100,
max_backoff_ms: 10_000,
backoff_multiplier: 2.0,
};
let limiter = RestartLimiter::new(config);
let peek1 = limiter.peek_backoff();
let peek2 = limiter.peek_backoff();
let peek3 = limiter.peek_backoff();
assert_eq!(peek1, Duration::from_millis(100));
assert_eq!(peek2, Duration::from_millis(100));
assert_eq!(peek3, Duration::from_millis(100));
assert_eq!(limiter.consecutive_restarts(), 0);
assert_eq!(limiter.restarts_in_window(), 0);
}
#[test]
fn clone_resets_state() {
let config = RestartLimiterConfig {
enabled: true,
max_restarts: 10,
window_secs: 60,
..Default::default()
};
let mut limiter = RestartLimiter::new(config);
let _ = limiter.record_restart();
let _ = limiter.record_restart();
assert_eq!(limiter.consecutive_restarts(), 2);
assert_eq!(limiter.restarts_in_window(), 2);
let cloned = limiter.clone();
assert_eq!(cloned.consecutive_restarts(), 0);
assert_eq!(cloned.restarts_in_window(), 0);
}
#[test]
fn restart_limit_exceeded_error_display() {
let err = RestartLimitExceeded {
attempts: 5,
max_restarts: 5,
window_secs: 60,
};
let display = format!("{err}");
assert!(display.contains("5 attempts"));
assert!(display.contains("max 5"));
assert!(display.contains("60 seconds"));
}
}