use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
Closed,
Open,
HalfOpen,
}
impl std::fmt::Display for CircuitState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Closed => write!(f, "Closed"),
Self::Open => write!(f, "Open"),
Self::HalfOpen => write!(f, "HalfOpen"),
}
}
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub failure_threshold: u32,
pub cooldown_period: Duration,
pub half_open_max_probes: u32,
pub minimum_request_count: u32,
pub failure_rate_threshold: Option<f64>,
pub name: String,
}
impl CircuitBreakerConfig {
#[must_use]
pub fn new(name: impl Into<String>, failure_threshold: u32, cooldown_period: Duration) -> Self {
Self {
failure_threshold,
cooldown_period,
half_open_max_probes: 1,
minimum_request_count: 1,
failure_rate_threshold: None,
name: name.into(),
}
}
#[must_use]
pub fn with_half_open_probes(mut self, probes: u32) -> Self {
self.half_open_max_probes = probes;
self
}
#[must_use]
pub fn with_minimum_requests(mut self, count: u32) -> Self {
self.minimum_request_count = count;
self
}
#[must_use]
pub fn with_failure_rate_threshold(mut self, rate: f64) -> Self {
self.failure_rate_threshold = Some(rate.clamp(0.0, 1.0));
self
}
}
#[derive(Debug, Clone, Default)]
pub struct CircuitBreakerMetrics {
pub total_requests: u64,
pub total_successes: u64,
pub total_failures: u64,
pub total_rejected: u64,
pub consecutive_failures: u32,
pub trip_count: u64,
pub recovery_count: u64,
}
impl CircuitBreakerMetrics {
#[must_use]
pub fn failure_rate(&self) -> f64 {
if self.total_requests == 0 {
0.0
} else {
self.total_failures as f64 / self.total_requests as f64
}
}
}
#[derive(Debug, Clone)]
pub struct CircuitBreaker {
config: CircuitBreakerConfig,
state: CircuitState,
metrics: CircuitBreakerMetrics,
opened_at_ms: Option<u64>,
half_open_probes: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CircuitDecision {
Allow,
Reject {
remaining_cooldown_ms: u64,
},
}
impl CircuitBreaker {
#[must_use]
pub fn new(config: CircuitBreakerConfig) -> Self {
Self {
config,
state: CircuitState::Closed,
metrics: CircuitBreakerMetrics::default(),
opened_at_ms: None,
half_open_probes: 0,
}
}
#[must_use]
pub fn state(&self) -> CircuitState {
self.state
}
#[must_use]
pub fn metrics(&self) -> &CircuitBreakerMetrics {
&self.metrics
}
#[must_use]
pub fn config(&self) -> &CircuitBreakerConfig {
&self.config
}
#[must_use]
pub fn check(&mut self, now_ms: u64) -> CircuitDecision {
match self.state {
CircuitState::Closed => CircuitDecision::Allow,
CircuitState::Open => {
let opened = self.opened_at_ms.unwrap_or(now_ms);
let elapsed_ms = now_ms.saturating_sub(opened);
let cooldown_ms = self.config.cooldown_period.as_millis() as u64;
if elapsed_ms >= cooldown_ms {
self.state = CircuitState::HalfOpen;
self.half_open_probes = 0;
CircuitDecision::Allow
} else {
self.metrics.total_rejected += 1;
CircuitDecision::Reject {
remaining_cooldown_ms: cooldown_ms.saturating_sub(elapsed_ms),
}
}
}
CircuitState::HalfOpen => {
if self.half_open_probes < self.config.half_open_max_probes {
CircuitDecision::Allow
} else {
self.metrics.total_rejected += 1;
CircuitDecision::Reject {
remaining_cooldown_ms: 0,
}
}
}
}
}
pub fn record_success(&mut self) {
self.metrics.total_requests += 1;
self.metrics.total_successes += 1;
self.metrics.consecutive_failures = 0;
match self.state {
CircuitState::HalfOpen => {
self.half_open_probes += 1;
if self.half_open_probes >= self.config.half_open_max_probes {
self.state = CircuitState::Closed;
self.opened_at_ms = None;
self.metrics.recovery_count += 1;
}
}
CircuitState::Open => {
}
CircuitState::Closed => {}
}
}
pub fn record_failure(&mut self, now_ms: u64) {
self.metrics.total_requests += 1;
self.metrics.total_failures += 1;
self.metrics.consecutive_failures += 1;
match self.state {
CircuitState::Closed => {
if self.should_trip() {
self.trip(now_ms);
}
}
CircuitState::HalfOpen => {
self.half_open_probes += 1;
self.trip(now_ms);
}
CircuitState::Open => {}
}
}
pub fn reset(&mut self) {
self.state = CircuitState::Closed;
self.opened_at_ms = None;
self.half_open_probes = 0;
self.metrics.consecutive_failures = 0;
}
fn should_trip(&self) -> bool {
if self.metrics.total_requests < u64::from(self.config.minimum_request_count) {
return false;
}
if self.metrics.consecutive_failures < self.config.failure_threshold {
return false;
}
if let Some(rate_threshold) = self.config.failure_rate_threshold {
if self.metrics.failure_rate() < rate_threshold {
return false;
}
}
true
}
fn trip(&mut self, now_ms: u64) {
self.state = CircuitState::Open;
self.opened_at_ms = Some(now_ms);
self.half_open_probes = 0;
self.metrics.trip_count += 1;
}
}
#[derive(Debug, Default)]
pub struct CircuitBreakerRegistry {
breakers: HashMap<String, CircuitBreaker>,
}
impl CircuitBreakerRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, breaker: CircuitBreaker) {
let name = breaker.config.name.clone();
self.breakers.insert(name, breaker);
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut CircuitBreaker> {
self.breakers.get_mut(name)
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&CircuitBreaker> {
self.breakers.get(name)
}
#[must_use]
pub fn check(&mut self, name: &str, now_ms: u64) -> CircuitDecision {
match self.breakers.get_mut(name) {
Some(breaker) => breaker.check(now_ms),
None => CircuitDecision::Allow,
}
}
pub fn record_success(&mut self, name: &str) {
if let Some(b) = self.breakers.get_mut(name) {
b.record_success();
}
}
pub fn record_failure(&mut self, name: &str, now_ms: u64) {
if let Some(b) = self.breakers.get_mut(name) {
b.record_failure(now_ms);
}
}
#[must_use]
pub fn count(&self) -> usize {
self.breakers.len()
}
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.breakers.keys().map(String::as_str).collect()
}
pub fn remove(&mut self, name: &str) -> Option<CircuitBreaker> {
self.breakers.remove(name)
}
#[must_use]
pub fn summary(&self) -> Vec<CircuitBreakerSummary> {
self.breakers
.values()
.map(|b| CircuitBreakerSummary {
name: b.config.name.clone(),
state: b.state,
consecutive_failures: b.metrics.consecutive_failures,
total_requests: b.metrics.total_requests,
failure_rate: b.metrics.failure_rate(),
trip_count: b.metrics.trip_count,
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerSummary {
pub name: String,
pub state: CircuitState,
pub consecutive_failures: u32,
pub total_requests: u64,
pub failure_rate: f64,
pub trip_count: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(threshold: u32, cooldown_ms: u64) -> CircuitBreakerConfig {
CircuitBreakerConfig::new(
"test-breaker",
threshold,
Duration::from_millis(cooldown_ms),
)
}
#[test]
fn test_circuit_state_display() {
assert_eq!(CircuitState::Closed.to_string(), "Closed");
assert_eq!(CircuitState::Open.to_string(), "Open");
assert_eq!(CircuitState::HalfOpen.to_string(), "HalfOpen");
}
#[test]
fn test_config_creation() {
let config = make_config(5, 10_000);
assert_eq!(config.failure_threshold, 5);
assert_eq!(config.cooldown_period, Duration::from_secs(10));
assert_eq!(config.half_open_max_probes, 1);
assert_eq!(config.minimum_request_count, 1);
assert!(config.failure_rate_threshold.is_none());
}
#[test]
fn test_config_with_builder_methods() {
let config = make_config(3, 5000)
.with_half_open_probes(3)
.with_minimum_requests(10)
.with_failure_rate_threshold(0.5);
assert_eq!(config.half_open_max_probes, 3);
assert_eq!(config.minimum_request_count, 10);
assert_eq!(config.failure_rate_threshold, Some(0.5));
}
#[test]
fn test_failure_rate_threshold_clamp() {
let config = make_config(3, 5000).with_failure_rate_threshold(1.5);
assert_eq!(config.failure_rate_threshold, Some(1.0));
}
#[test]
fn test_metrics_failure_rate_zero_requests() {
let metrics = CircuitBreakerMetrics::default();
assert!((metrics.failure_rate() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_metrics_failure_rate_computed() {
let metrics = CircuitBreakerMetrics {
total_requests: 10,
total_failures: 3,
..Default::default()
};
assert!((metrics.failure_rate() - 0.3).abs() < f64::EPSILON);
}
#[test]
fn test_starts_closed() {
let cb = CircuitBreaker::new(make_config(3, 5000));
assert_eq!(cb.state(), CircuitState::Closed);
}
#[test]
fn test_closed_allows_requests() {
let mut cb = CircuitBreaker::new(make_config(3, 5000));
assert_eq!(cb.check(0), CircuitDecision::Allow);
}
#[test]
fn test_trips_after_threshold_failures() {
let mut cb = CircuitBreaker::new(make_config(3, 5000));
cb.record_failure(100);
assert_eq!(cb.state(), CircuitState::Closed);
cb.record_failure(200);
assert_eq!(cb.state(), CircuitState::Closed);
cb.record_failure(300);
assert_eq!(cb.state(), CircuitState::Open);
assert_eq!(cb.metrics().trip_count, 1);
}
#[test]
fn test_open_rejects_requests() {
let mut cb = CircuitBreaker::new(make_config(2, 5000));
cb.record_failure(100);
cb.record_failure(200);
assert_eq!(cb.state(), CircuitState::Open);
match cb.check(300) {
CircuitDecision::Reject {
remaining_cooldown_ms,
} => {
assert!(remaining_cooldown_ms > 0);
}
CircuitDecision::Allow => panic!("expected reject"),
}
}
#[test]
fn test_transitions_to_half_open_after_cooldown() {
let mut cb = CircuitBreaker::new(make_config(2, 1000));
cb.record_failure(0);
cb.record_failure(100);
assert_eq!(cb.state(), CircuitState::Open);
assert!(matches!(cb.check(500), CircuitDecision::Reject { .. }));
assert_eq!(cb.check(1200), CircuitDecision::Allow);
assert_eq!(cb.state(), CircuitState::HalfOpen);
}
#[test]
fn test_half_open_success_recovers() {
let mut cb = CircuitBreaker::new(make_config(2, 1000));
cb.record_failure(0);
cb.record_failure(100);
assert_eq!(cb.check(1200), CircuitDecision::Allow);
assert_eq!(cb.state(), CircuitState::HalfOpen);
cb.record_success();
assert_eq!(cb.state(), CircuitState::Closed);
assert_eq!(cb.metrics().recovery_count, 1);
}
#[test]
fn test_half_open_failure_re_opens() {
let mut cb = CircuitBreaker::new(make_config(2, 1000));
cb.record_failure(0);
cb.record_failure(100);
assert_eq!(cb.check(1200), CircuitDecision::Allow);
cb.record_failure(1300);
assert_eq!(cb.state(), CircuitState::Open);
assert_eq!(cb.metrics().trip_count, 2);
}
#[test]
fn test_half_open_max_probes() {
let config = make_config(2, 1000).with_half_open_probes(2);
let mut cb = CircuitBreaker::new(config);
cb.record_failure(0);
cb.record_failure(100);
assert_eq!(cb.check(1200), CircuitDecision::Allow);
assert_eq!(cb.state(), CircuitState::HalfOpen);
assert_eq!(cb.check(1300), CircuitDecision::Allow);
cb.record_success(); cb.record_success(); assert_eq!(cb.state(), CircuitState::Closed);
}
#[test]
fn test_success_resets_consecutive_failures() {
let mut cb = CircuitBreaker::new(make_config(3, 5000));
cb.record_failure(100);
cb.record_failure(200);
assert_eq!(cb.metrics().consecutive_failures, 2);
cb.record_success();
assert_eq!(cb.metrics().consecutive_failures, 0);
cb.record_failure(300);
cb.record_failure(400);
assert_eq!(cb.state(), CircuitState::Closed);
}
#[test]
fn test_minimum_request_count() {
let config = make_config(2, 5000).with_minimum_requests(5);
let mut cb = CircuitBreaker::new(config);
cb.record_failure(100);
cb.record_failure(200);
assert_eq!(cb.state(), CircuitState::Closed);
cb.record_success();
cb.record_success();
cb.record_failure(300); cb.record_failure(400); assert_eq!(cb.state(), CircuitState::Open);
}
#[test]
fn test_failure_rate_threshold() {
let config = make_config(2, 5000)
.with_minimum_requests(1)
.with_failure_rate_threshold(0.5);
let mut cb = CircuitBreaker::new(config);
for _ in 0..10 {
cb.record_success();
}
cb.record_failure(100);
cb.record_failure(200);
assert_eq!(cb.state(), CircuitState::Closed);
}
#[test]
fn test_reset() {
let mut cb = CircuitBreaker::new(make_config(2, 5000));
cb.record_failure(0);
cb.record_failure(100);
assert_eq!(cb.state(), CircuitState::Open);
cb.reset();
assert_eq!(cb.state(), CircuitState::Closed);
assert_eq!(cb.metrics().consecutive_failures, 0);
}
#[test]
fn test_registry_register_and_get() {
let mut registry = CircuitBreakerRegistry::new();
registry.register(CircuitBreaker::new(make_config(3, 5000)));
assert_eq!(registry.count(), 1);
assert!(registry.get("test-breaker").is_some());
}
#[test]
fn test_registry_check_unknown_allows() {
let mut registry = CircuitBreakerRegistry::new();
assert_eq!(registry.check("nonexistent", 0), CircuitDecision::Allow);
}
#[test]
fn test_registry_record_success_failure() {
let mut registry = CircuitBreakerRegistry::new();
registry.register(CircuitBreaker::new(make_config(2, 5000)));
registry.record_failure("test-breaker", 100);
registry.record_failure("test-breaker", 200);
let breaker = registry.get("test-breaker").expect("should exist");
assert_eq!(breaker.state(), CircuitState::Open);
registry.record_success("nonexistent");
}
#[test]
fn test_registry_remove() {
let mut registry = CircuitBreakerRegistry::new();
registry.register(CircuitBreaker::new(make_config(3, 5000)));
let removed = registry.remove("test-breaker");
assert!(removed.is_some());
assert_eq!(registry.count(), 0);
}
#[test]
fn test_registry_names() {
let mut registry = CircuitBreakerRegistry::new();
registry.register(CircuitBreaker::new(CircuitBreakerConfig::new(
"alpha",
3,
Duration::from_secs(5),
)));
registry.register(CircuitBreaker::new(CircuitBreakerConfig::new(
"beta",
3,
Duration::from_secs(5),
)));
let mut names = registry.names();
names.sort();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn test_registry_summary() {
let mut registry = CircuitBreakerRegistry::new();
let mut cb = CircuitBreaker::new(make_config(2, 5000));
cb.record_failure(0);
cb.record_failure(100);
registry.register(cb);
let summary = registry.summary();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].state, CircuitState::Open);
assert_eq!(summary[0].trip_count, 1);
}
#[test]
fn test_full_lifecycle() {
let mut cb = CircuitBreaker::new(make_config(3, 2000).with_half_open_probes(1));
cb.record_success();
cb.record_failure(100);
cb.record_failure(200);
cb.record_failure(300);
assert_eq!(cb.state(), CircuitState::Open);
assert!(matches!(cb.check(500), CircuitDecision::Reject { .. }));
assert!(matches!(cb.check(1000), CircuitDecision::Reject { .. }));
assert_eq!(cb.check(2500), CircuitDecision::Allow);
assert_eq!(cb.state(), CircuitState::HalfOpen);
cb.record_success();
assert_eq!(cb.state(), CircuitState::Closed);
assert_eq!(cb.metrics().recovery_count, 1);
assert_eq!(cb.metrics().trip_count, 1);
assert_eq!(cb.metrics().total_requests, 5);
}
#[test]
fn test_rejected_counter() {
let mut cb = CircuitBreaker::new(make_config(1, 5000));
cb.record_failure(0);
assert_eq!(cb.state(), CircuitState::Open);
let _ = cb.check(100);
let _ = cb.check(200);
let _ = cb.check(300);
assert_eq!(cb.metrics().total_rejected, 3);
}
}