use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
Closed,
Open,
HalfOpen,
}
impl CircuitState {
fn code(self) -> u8 {
match self {
Self::Closed => 0,
Self::Open => 1,
Self::HalfOpen => 2,
}
}
}
#[derive(Debug)]
struct Inner {
state: CircuitState,
consecutive_failures: u32,
last_failure_ms: u64,
probe_available: bool,
}
pub struct CircuitBreaker {
inner: Mutex<Inner>,
failure_threshold: u32,
reset_timeout: Duration,
health_state: Arc<AtomicU8>,
}
impl CircuitBreaker {
#[must_use]
pub fn new(failure_threshold: u32, reset_timeout: Duration) -> Self {
let health_state = Arc::new(AtomicU8::new(CircuitState::Closed.code()));
#[cfg(feature = "health")]
{
let hs = Arc::clone(&health_state);
crate::health::HealthRegistry::register("circuit_breaker", move || {
match hs.load(Ordering::Acquire) {
0 => crate::health::HealthStatus::Healthy, 2 => crate::health::HealthStatus::Degraded, _ => crate::health::HealthStatus::Unhealthy, }
});
}
Self {
inner: Mutex::new(Inner {
state: CircuitState::Closed,
consecutive_failures: 0,
last_failure_ms: 0,
probe_available: false,
}),
failure_threshold,
reset_timeout,
health_state,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn set_state(&self, inner: &mut Inner, new: CircuitState) {
inner.state = new;
self.health_state.store(new.code(), Ordering::Release);
}
fn reset_elapsed(&self, inner: &Inner) -> bool {
let now = current_epoch_millis();
Duration::from_millis(now.saturating_sub(inner.last_failure_ms)) >= self.reset_timeout
}
pub async fn allow_request(&self) -> bool {
let mut inner = self.lock();
match inner.state {
CircuitState::Closed => true,
CircuitState::Open => {
if self.reset_elapsed(&inner) {
self.set_state(&mut inner, CircuitState::HalfOpen);
inner.probe_available = false;
true
} else {
false
}
}
CircuitState::HalfOpen => {
if inner.probe_available {
inner.probe_available = false;
true
} else {
false
}
}
}
}
pub async fn state(&self) -> CircuitState {
let inner = self.lock();
if inner.state == CircuitState::Open && self.reset_elapsed(&inner) {
CircuitState::HalfOpen
} else {
inner.state
}
}
pub async fn is_closed(&self) -> bool {
self.state().await == CircuitState::Closed
}
pub async fn is_open(&self) -> bool {
self.state().await == CircuitState::Open
}
pub async fn record_success(&self) {
let mut inner = self.lock();
inner.consecutive_failures = 0;
inner.probe_available = false;
self.set_state(&mut inner, CircuitState::Closed);
}
pub async fn record_failure(&self) {
let mut inner = self.lock();
inner.consecutive_failures = inner.consecutive_failures.saturating_add(1);
inner.last_failure_ms = current_epoch_millis();
if inner.consecutive_failures >= self.failure_threshold {
inner.probe_available = false;
self.set_state(&mut inner, CircuitState::Open);
}
}
#[must_use]
pub fn consecutive_failures(&self) -> u32 {
self.lock().consecutive_failures
}
pub async fn reset(&self) {
let mut inner = self.lock();
inner.consecutive_failures = 0;
inner.last_failure_ms = 0;
inner.probe_available = false;
self.set_state(&mut inner, CircuitState::Closed);
}
}
impl std::fmt::Debug for CircuitBreaker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CircuitBreaker")
.field("failure_threshold", &self.failure_threshold)
.field("reset_timeout", &self.reset_timeout)
.field("consecutive_failures", &self.consecutive_failures())
.finish_non_exhaustive()
}
}
fn current_epoch_millis() -> u64 {
use std::time::SystemTime;
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_initial_state_is_closed() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
assert_eq!(cb.state().await, CircuitState::Closed);
assert!(cb.is_closed().await);
}
#[tokio::test]
async fn test_opens_after_threshold() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
cb.record_failure().await;
assert!(cb.is_closed().await);
cb.record_failure().await;
assert!(cb.is_closed().await);
cb.record_failure().await;
assert!(cb.is_open().await);
assert_eq!(cb.consecutive_failures(), 3);
}
#[tokio::test]
async fn test_success_resets_failures() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
cb.record_failure().await;
cb.record_failure().await;
assert_eq!(cb.consecutive_failures(), 2);
cb.record_success().await;
assert_eq!(cb.consecutive_failures(), 0);
assert!(cb.is_closed().await);
}
#[tokio::test]
async fn test_half_open_after_timeout() {
let cb = CircuitBreaker::new(1, Duration::from_millis(50));
cb.record_failure().await;
assert!(cb.is_open().await);
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(cb.state().await, CircuitState::HalfOpen);
}
#[tokio::test]
async fn test_half_open_success_closes() {
let cb = CircuitBreaker::new(1, Duration::from_millis(10));
cb.record_failure().await;
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(cb.state().await, CircuitState::HalfOpen);
cb.record_success().await;
assert!(cb.is_closed().await);
}
#[tokio::test]
async fn test_half_open_failure_reopens() {
let cb = CircuitBreaker::new(1, Duration::from_millis(10));
cb.record_failure().await;
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(cb.state().await, CircuitState::HalfOpen);
cb.record_failure().await;
assert!(cb.is_open().await);
}
#[tokio::test]
async fn test_reset() {
let cb = CircuitBreaker::new(1, Duration::from_secs(30));
cb.record_failure().await;
assert!(cb.is_open().await);
cb.reset().await;
assert!(cb.is_closed().await);
assert_eq!(cb.consecutive_failures(), 0);
}
#[tokio::test]
async fn allow_request_admits_exactly_one_half_open_probe() {
let cb = CircuitBreaker::new(1, Duration::from_millis(10));
assert!(cb.allow_request().await);
cb.record_failure().await;
assert!(!cb.allow_request().await);
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(cb.allow_request().await, "first caller takes the probe");
assert!(
!cb.allow_request().await,
"second concurrent caller must be refused -- one probe only"
);
cb.record_success().await;
assert!(cb.allow_request().await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_failures_and_probes_stay_consistent() {
let cb = Arc::new(CircuitBreaker::new(5, Duration::from_millis(10)));
let mut handles = Vec::new();
for _ in 0..50 {
let cb = Arc::clone(&cb);
handles.push(tokio::spawn(async move {
cb.record_failure().await;
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(cb.consecutive_failures(), 50);
assert!(cb.is_open().await);
tokio::time::sleep(Duration::from_millis(20)).await;
let mut probes = Vec::new();
for _ in 0..50 {
let cb = Arc::clone(&cb);
probes.push(tokio::spawn(async move { cb.allow_request().await }));
}
let mut admitted = 0;
for p in probes {
if p.await.unwrap() {
admitted += 1;
}
}
assert_eq!(admitted, 1, "exactly one probe admitted, got {admitted}");
}
}