use super::*;
use std::assert_matches;
fn breaker(policy: CircuitBreakerPolicy) -> (OriginBreaker, ManualClock) {
let clock = ManualClock::new();
let b = OriginBreaker::new(policy, Arc::new(clock.clone()), None);
(b, clock)
}
fn policy() -> CircuitBreakerPolicy {
CircuitBreakerPolicy {
enabled: true,
failure_threshold: 3,
cooldown_ms: 1_000,
half_open_max_calls: 1,
}
}
fn admit(b: &OriginBreaker) -> TrialGuard<'_> {
match b.acquire() {
Admission::Proceed(_, guard) => guard,
Admission::ShortCircuit => panic!("expected admission, got short-circuit"),
}
}
fn assert_short_circuit(b: &OriginBreaker) {
assert_matches!(
b.acquire(),
Admission::ShortCircuit,
"expected short-circuit"
);
}
fn drive(b: &OriginBreaker, outcome: OriginOutcome) {
admit(b).record(outcome);
}
#[test]
fn starts_closed_and_proceeds() {
let (b, _clk) = breaker(policy());
assert_eq!(b.state(), BreakerState::Closed);
assert_matches!(b.acquire(), Admission::Proceed(BreakerState::Closed, _));
}
#[test]
fn trips_open_after_threshold_consecutive_failures() {
let (b, _clk) = breaker(policy());
for _ in 0..2 {
drive(&b, OriginOutcome::Unavailable);
assert_eq!(
b.state(),
BreakerState::Closed,
"still closed below threshold"
);
}
drive(&b, OriginOutcome::Unavailable);
assert_eq!(b.state(), BreakerState::Open, "threshold reached -> open");
}
#[test]
fn success_resets_consecutive_failure_count() {
let (b, _clk) = breaker(policy());
drive(&b, OriginOutcome::Unavailable);
drive(&b, OriginOutcome::Unavailable);
drive(&b, OriginOutcome::Available);
drive(&b, OriginOutcome::Unavailable);
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn open_short_circuits_until_cooldown() {
let (b, clk) = breaker(policy());
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
assert_eq!(b.state(), BreakerState::Open);
assert_short_circuit(&b);
clk.advance(Duration::from_millis(999));
assert_short_circuit(&b);
clk.advance(Duration::from_millis(1));
assert_matches!(b.acquire(), Admission::Proceed(BreakerState::HalfOpen, _));
assert_eq!(b.state(), BreakerState::HalfOpen);
}
#[test]
fn half_open_success_closes() {
let (b, clk) = breaker(policy());
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
admit(&b).record(OriginOutcome::Available);
assert_eq!(b.state(), BreakerState::Closed, "trial success -> closed");
let _ = &clk;
}
#[test]
fn half_open_failure_reopens() {
let (b, clk) = breaker(policy());
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
admit(&b).record(OriginOutcome::Unavailable);
assert_eq!(b.state(), BreakerState::Open, "trial failure -> open");
assert_short_circuit(&b);
}
#[test]
fn half_open_caps_trial_concurrency() {
let p = CircuitBreakerPolicy {
half_open_max_calls: 1,
..policy()
};
let (b, clk) = breaker(p);
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
let _trial = admit(&b);
assert_short_circuit(&b);
}
#[test]
fn half_open_allows_multiple_trials_when_configured() {
let p = CircuitBreakerPolicy {
half_open_max_calls: 3,
..policy()
};
let (b, clk) = breaker(p);
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
let _t1 = admit(&b);
let _t2 = admit(&b);
let _t3 = admit(&b);
assert_short_circuit(&b);
}
#[test]
fn permanent_outcome_does_not_trip() {
let (b, _clk) = breaker(policy());
for _ in 0..100 {
drive(&b, OriginOutcome::Available);
}
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn disabled_policy_always_proceeds_and_never_trips() {
let (b, _clk) = breaker(CircuitBreakerPolicy::disabled());
for _ in 0..100 {
let guard = match b.acquire() {
Admission::Proceed(BreakerState::Closed, guard) => guard,
other => panic!("disabled policy must proceed closed, got {other:?}"),
};
guard.record(OriginOutcome::Unavailable);
}
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn zero_threshold_policy_is_inactive() {
let p = CircuitBreakerPolicy {
enabled: true,
failure_threshold: 0,
..policy()
};
let (b, _clk) = breaker(p);
for _ in 0..100 {
drive(&b, OriginOutcome::Unavailable);
}
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn metrics_count_trip_recovery_and_short_circuit() {
let metrics = Arc::new(CacheMetrics::default());
let clock = ManualClock::new();
let b = OriginBreaker::new(
policy(),
Arc::new(clock.clone()),
Some(Arc::clone(&metrics)),
);
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
assert_eq!(metrics.circuit_breaker_trips.get(), 1);
assert_short_circuit(&b);
assert_eq!(metrics.circuit_breaker_short_circuits.get(), 1);
clock.advance(Duration::from_secs(1));
admit(&b).record(OriginOutcome::Available);
assert_eq!(metrics.circuit_breaker_recoveries.get(), 1);
}
#[test]
fn dropped_trial_guard_releases_half_open_slot() {
let (b, clk) = breaker(policy());
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
{
let guard = admit(&b);
assert_eq!(b.state(), BreakerState::HalfOpen);
assert_short_circuit(&b);
drop(guard); }
let guard = admit(&b);
assert_eq!(
b.state(),
BreakerState::HalfOpen,
"still probing; slot was reclaimed, not leaked"
);
guard.record(OriginOutcome::Available);
assert_eq!(b.state(), BreakerState::Closed, "trial success -> closed");
}
#[test]
fn mixed_recorded_and_cancelled_trials_reclaim_full_budget() {
let p = CircuitBreakerPolicy {
half_open_max_calls: 3,
..policy()
};
let (b, clk) = breaker(p);
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
let g1 = admit(&b);
let g2 = admit(&b);
let g3 = admit(&b);
assert_short_circuit(&b);
drop(g1);
drop(g2);
g3.record(OriginOutcome::Available);
assert_eq!(b.state(), BreakerState::Closed);
for _ in 0..3 {
drive(&b, OriginOutcome::Unavailable);
}
clk.advance(Duration::from_secs(1));
let _t1 = admit(&b);
let _t2 = admit(&b);
let _t3 = admit(&b);
assert_short_circuit(&b);
}