use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct RetryBanner {
pub attempt: u32,
pub deadline: Instant,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub enum RetryState {
#[default]
Idle,
Active(RetryBanner),
Failed {
reason: String,
#[allow(dead_code)]
since: Instant,
},
}
impl RetryState {
#[must_use]
pub fn seconds_remaining(&self) -> Option<u64> {
match self {
Self::Active(banner) => Some(
banner
.deadline
.saturating_duration_since(Instant::now())
.as_secs(),
),
_ => None,
}
}
#[cfg(test)]
#[must_use]
pub fn is_failed(&self) -> bool {
matches!(self, Self::Failed { .. })
}
}
fn cell() -> &'static Mutex<RetryState> {
static STATE: OnceLock<Mutex<RetryState>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(RetryState::Idle))
}
#[must_use]
pub fn snapshot() -> RetryState {
cell().lock().map(|s| s.clone()).unwrap_or(RetryState::Idle)
}
pub fn start(attempt: u32, delay: Duration, reason: impl Into<String>) {
let banner = RetryBanner {
attempt,
deadline: Instant::now() + delay,
reason: reason.into(),
};
if let Ok(mut s) = cell().lock() {
*s = RetryState::Active(banner);
}
}
pub fn succeeded() {
if let Ok(mut s) = cell().lock() {
*s = RetryState::Idle;
}
}
pub fn failed(reason: impl Into<String>) {
if let Ok(mut s) = cell().lock() {
*s = RetryState::Failed {
reason: reason.into(),
since: Instant::now(),
};
}
}
pub fn clear() {
if let Ok(mut s) = cell().lock() {
*s = RetryState::Idle;
}
}
#[cfg(test)]
pub fn test_guard() -> std::sync::MutexGuard<'static, ()> {
static GUARD: Mutex<()> = Mutex::new(());
GUARD.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> std::sync::MutexGuard<'static, ()> {
let g = test_guard();
clear();
g
}
#[test]
fn idle_by_default_after_clear() {
let _g = setup();
assert!(matches!(snapshot(), RetryState::Idle));
assert_eq!(snapshot().seconds_remaining(), None);
}
#[test]
fn start_then_succeeded_returns_to_idle() {
let _g = setup();
start(1, Duration::from_secs(5), "rate limited");
let s = snapshot();
assert!(matches!(s, RetryState::Active(_)));
let remaining = s.seconds_remaining().unwrap();
assert!(remaining <= 5, "{remaining}");
succeeded();
assert!(matches!(snapshot(), RetryState::Idle));
}
#[test]
fn failed_persists_until_clear() {
let _g = setup();
failed("upstream 500");
let s = snapshot();
assert!(s.is_failed());
if let RetryState::Failed { reason, .. } = s {
assert_eq!(reason, "upstream 500");
} else {
panic!("expected Failed");
}
clear();
assert!(matches!(snapshot(), RetryState::Idle));
}
#[test]
fn deadline_in_past_yields_zero_remaining() {
let _g = setup();
if let Ok(mut s) = cell().lock() {
*s = RetryState::Active(RetryBanner {
attempt: 2,
deadline: Instant::now() - Duration::from_secs(1),
reason: "test".into(),
});
}
assert_eq!(snapshot().seconds_remaining(), Some(0));
clear();
}
}