#![allow(
clippy::missing_panics_doc,
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::new_without_default,
clippy::missing_const_for_fn,
clippy::items_after_statements,
clippy::cast_precision_loss,
clippy::collapsible_if
)]
#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CircuitState {
Closed,
Open,
HalfOpen,
}
impl CircuitState {
pub const fn as_str(self) -> &'static str {
match self {
Self::Closed => "CLOSED",
Self::Open => "OPEN",
Self::HalfOpen => "HALF_OPEN",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CancelledCallOutcome {
#[default]
Slow,
Failure,
}
impl CancelledCallOutcome {
pub const fn as_str(self) -> &'static str {
match self {
Self::Slow => "slow",
Self::Failure => "failure",
}
}
}
impl std::str::FromStr for CancelledCallOutcome {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"slow" => Ok(Self::Slow),
"failure" => Ok(Self::Failure),
other => Err(format!(
"unknown cancelled_call_outcome {other:?}: use \"slow\" or \"failure\""
)),
}
}
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerPolicy {
pub failure_ratio_threshold: f64,
pub sample_window: Duration,
pub minimum_sample_count: u64,
pub open_duration: Duration,
pub half_open_trial_count: u64,
pub slow_call_duration_threshold: Option<Duration>,
pub slow_call_rate_threshold: f64,
pub cancelled_call_outcome: CancelledCallOutcome,
}
impl Default for CircuitBreakerPolicy {
fn default() -> Self {
Self {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 10,
open_duration: Duration::from_secs(60),
half_open_trial_count: 3,
slow_call_duration_threshold: Some(Duration::from_secs(60)),
slow_call_rate_threshold: 1.0,
cancelled_call_outcome: CancelledCallOutcome::Slow,
}
}
}
impl CircuitBreakerPolicy {
pub fn from_config(rc: &crate::config::ResilienceConfig, name: &str) -> Self {
let mut policy = Self::default();
policy.apply(&rc.circuit_breaker.defaults);
if let Some(host_cfg) = rc.circuit_breaker.hosts.get(name) {
policy.apply(host_cfg);
}
policy
}
fn apply(&mut self, cfg: &crate::config::CircuitBreakerPolicyConfig) {
if let Some(ratio) = cfg.failure_ratio_threshold {
self.failure_ratio_threshold = clamp_ratio(ratio);
}
if let Some(window) = cfg.sample_window_secs {
self.sample_window = Duration::from_secs(window);
}
if let Some(count) = cfg.minimum_sample_count {
self.minimum_sample_count = count;
}
if let Some(duration) = cfg.open_duration_secs {
self.open_duration = Duration::from_secs(duration);
}
if let Some(trials) = cfg.half_open_trial_count {
self.half_open_trial_count = trials.max(1);
}
if let Some(ms) = cfg.slow_call_duration_threshold_ms {
self.slow_call_duration_threshold = (ms > 0).then(|| Duration::from_millis(ms));
}
if let Some(ratio) = cfg.slow_call_rate_threshold {
self.slow_call_rate_threshold = clamp_ratio(ratio);
}
if let Some(outcome) = cfg.cancelled_call_outcome {
self.cancelled_call_outcome = outcome;
}
}
fn clamped(mut self) -> Self {
self.failure_ratio_threshold = clamp_ratio(self.failure_ratio_threshold);
self.slow_call_rate_threshold = clamp_ratio(self.slow_call_rate_threshold);
self.half_open_trial_count = self.half_open_trial_count.max(1);
self
}
fn is_slow(&self, elapsed: Duration) -> bool {
self.slow_call_duration_threshold
.is_some_and(|threshold| elapsed >= threshold)
}
}
fn clamp_ratio(ratio: f64) -> f64 {
ratio.clamp(0.000_1, 1.0)
}
#[derive(Debug, Error)]
pub enum CircuitBreakerError<E> {
#[error("circuit breaker is open")]
Open,
#[error("execution failed: {0}")]
Execution(E),
}
pub(crate) const WINDOW_BUCKETS: usize = 10;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct WindowCounts {
pub(crate) total: u64,
pub(crate) failed: u64,
pub(crate) slow: u64,
}
impl WindowCounts {
fn add(&mut self, other: Self) {
self.total = self.total.saturating_add(other.total);
self.failed = self.failed.saturating_add(other.failed);
self.slow = self.slow.saturating_add(other.slow);
}
pub(crate) fn failure_ratio(self) -> f64 {
ratio(self.failed, self.total)
}
pub(crate) fn slow_call_ratio(self) -> f64 {
ratio(self.slow, self.total)
}
}
fn ratio(part: u64, total: u64) -> f64 {
if total == 0 {
0.0
} else {
part as f64 / total as f64
}
}
#[derive(Debug, Clone, Copy, Default)]
struct Bucket {
epoch: u64,
counts: WindowCounts,
}
#[derive(Debug, Clone)]
pub(crate) struct SampleWindow {
origin: Instant,
width_nanos: u128,
buckets: [Bucket; WINDOW_BUCKETS],
}
impl SampleWindow {
pub(crate) fn new(window: Duration, origin: Instant) -> Self {
Self {
origin,
width_nanos: (window.as_nanos() / WINDOW_BUCKETS as u128).max(1),
buckets: [Bucket::default(); WINDOW_BUCKETS],
}
}
fn epoch(&self, now: Instant) -> u64 {
let slice = now.saturating_duration_since(self.origin).as_nanos() / self.width_nanos;
u64::try_from(slice).unwrap_or(u64::MAX)
}
fn slot(epoch: u64) -> usize {
#[allow(
clippy::cast_possible_truncation,
reason = "the modulus is < WINDOW_BUCKETS"
)]
let slot = (epoch % WINDOW_BUCKETS as u64) as usize;
slot
}
pub(crate) fn record(&mut self, now: Instant, failed: bool, slow: bool) {
let epoch = self.epoch(now);
if self
.buckets
.iter()
.any(|b| b.epoch >= epoch.saturating_add(WINDOW_BUCKETS as u64))
{
self.buckets = [Bucket::default(); WINDOW_BUCKETS];
}
let bucket = &mut self.buckets[Self::slot(epoch)];
if bucket.epoch != epoch {
*bucket = Bucket {
epoch,
counts: WindowCounts::default(),
};
}
bucket.counts.add(WindowCounts {
total: 1,
failed: u64::from(failed),
slow: u64::from(slow),
});
}
pub(crate) fn counts(&self, now: Instant) -> WindowCounts {
let epoch = self.epoch(now);
let mut sum = WindowCounts::default();
for bucket in &self.buckets {
if bucket.epoch <= epoch && epoch - bucket.epoch < WINDOW_BUCKETS as u64 {
sum.add(bucket.counts);
}
}
sum
}
pub(crate) fn reset(&mut self, window: Duration, origin: Instant) {
*self = Self::new(window, origin);
}
pub(crate) fn resize(&mut self, window: Duration, now: Instant) {
let counts = self.counts(now);
*self = Self::new(window, self.origin);
let epoch = self.epoch(now);
self.buckets[Self::slot(epoch)] = Bucket { epoch, counts };
}
}
#[derive(Clone)]
pub struct CircuitBreaker {
name: String,
pub(crate) inner: Arc<crate::sync_shim::Mutex<CircuitBreakerInner>>,
system_clock: bool,
}
pub(crate) struct CircuitBreakerInner {
pub(crate) state: CircuitState,
pub(crate) window: SampleWindow,
pub(crate) open_until: Option<Instant>,
pub(crate) half_open_successes: u64,
pub(crate) half_open_failures: u64,
pub(crate) half_open_in_flight: u64,
pub(crate) config: CircuitBreakerPolicy,
pub(crate) slow_calls_total: u64,
pub(crate) generation: u64,
}
impl CircuitBreakerInner {
fn half_open_if_due(&mut self, name: &str, now: Instant) {
if self.state == CircuitState::Open && self.open_until.is_some_and(|until| now >= until) {
self.half_open_successes = 0;
self.half_open_failures = 0;
self.half_open_in_flight = 0;
self.open_until = None;
self.transition_to(name, CircuitState::HalfOpen, 1.0, 0.0);
}
}
fn transition_to(
&mut self,
name: &str,
new_state: CircuitState,
failure_ratio: f64,
slow_call_ratio: f64,
) {
let old_state = self.state;
self.state = new_state;
self.generation = self.generation.wrapping_add(1);
tracing::info!(
circuit.name = name,
circuit.state = new_state.as_str(),
circuit.failure_ratio = failure_ratio,
circuit.slow_call_ratio = slow_call_ratio,
"circuit breaker state transition from {:?} to {:?}",
old_state,
new_state
);
}
fn record(&mut self, name: &str, now: Instant, generation: u64, failed: bool, slow: bool) {
if slow {
self.slow_calls_total = self.slow_calls_total.saturating_add(1);
}
if generation != self.generation {
return;
}
match self.state {
CircuitState::Closed => {
self.window.record(now, failed, slow);
let counts = self.window.counts(now);
if counts.total < self.config.minimum_sample_count {
return;
}
let failure_ratio = counts.failure_ratio();
let slow_call_ratio = counts.slow_call_ratio();
let too_slow = self.config.slow_call_duration_threshold.is_some()
&& slow_call_ratio >= self.config.slow_call_rate_threshold;
if failure_ratio >= self.config.failure_ratio_threshold || too_slow {
self.open_until = Some(crate::time_math::saturating_deadline(
now,
self.config.open_duration,
));
self.transition_to(name, CircuitState::Open, failure_ratio, slow_call_ratio);
}
}
CircuitState::HalfOpen => {
self.half_open_in_flight = self.half_open_in_flight.saturating_sub(1);
if failed || slow {
self.half_open_failures += 1;
self.open_until = Some(crate::time_math::saturating_deadline(
now,
self.config.open_duration,
));
let failure_ratio = if failed { 1.0 } else { 0.0 };
let slow_call_ratio = if slow { 1.0 } else { 0.0 };
self.transition_to(name, CircuitState::Open, failure_ratio, slow_call_ratio);
} else {
self.half_open_successes += 1;
if self.half_open_successes >= self.config.half_open_trial_count {
let window = self.config.sample_window;
self.window.reset(window, now);
self.transition_to(name, CircuitState::Closed, 0.0, 0.0);
}
}
}
CircuitState::Open => {}
}
}
}
impl CircuitBreaker {
pub fn new(name: impl Into<String>, config: CircuitBreakerPolicy) -> Self {
Self::with_clock(name, config, false)
}
fn new_on_system_clock(name: impl Into<String>, config: CircuitBreakerPolicy) -> Self {
Self::with_clock(name, config, true)
}
fn with_clock(
name: impl Into<String>,
config: CircuitBreakerPolicy,
system_clock: bool,
) -> Self {
let config = config.clamped();
let origin = read_clock(system_clock);
Self {
name: name.into(),
inner: Arc::new(crate::sync_shim::Mutex::new(CircuitBreakerInner {
state: CircuitState::Closed,
window: SampleWindow::new(config.sample_window, origin),
open_until: None,
half_open_successes: 0,
half_open_failures: 0,
half_open_in_flight: 0,
config,
slow_calls_total: 0,
generation: 0,
})),
system_clock,
}
}
fn now(&self) -> Instant {
read_clock(self.system_clock)
}
pub fn name(&self) -> &str {
&self.name
}
fn lock_inner(&self) -> crate::sync_shim::MutexGuard<'_, CircuitBreakerInner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn state(&self) -> CircuitState {
let mut inner = self.lock_inner();
inner.half_open_if_due(&self.name, self.now());
inner.state
}
pub fn config(&self) -> CircuitBreakerPolicy {
let inner = self.lock_inner();
inner.config.clone()
}
pub fn update_config(&self, config: CircuitBreakerPolicy) {
let config = config.clamped();
let mut inner = self.lock_inner();
let now = self.now();
if inner.config.sample_window != config.sample_window {
inner.window.resize(config.sample_window, now);
}
inner.config = config;
}
fn window_counts(&self) -> WindowCounts {
let inner = self.lock_inner();
inner.window.counts(self.now())
}
pub fn failure_ratio(&self) -> f64 {
self.window_counts().failure_ratio()
}
pub fn slow_call_ratio(&self) -> f64 {
self.window_counts().slow_call_ratio()
}
pub fn slow_calls_total(&self) -> u64 {
self.lock_inner().slow_calls_total
}
#[allow(clippy::significant_drop_tightening)]
pub(crate) fn before_call(&self) -> Result<u64, CircuitBreakerError<()>> {
let mut inner = self.lock_inner();
inner.half_open_if_due(&self.name, self.now());
match inner.state {
CircuitState::Open => Err(CircuitBreakerError::Open),
CircuitState::HalfOpen => {
let trial_count = inner.config.half_open_trial_count;
if inner.half_open_successes + inner.half_open_in_flight >= trial_count {
Err(CircuitBreakerError::Open)
} else {
inner.half_open_in_flight += 1;
Ok(inner.generation)
}
}
CircuitState::Closed => Ok(inner.generation),
}
}
pub(crate) fn admit(&self) -> Result<CircuitBreakerGuard, CircuitBreakerError<()>> {
let generation = self.before_call()?;
Ok(CircuitBreakerGuard::admitted(self.clone(), generation))
}
#[cfg(test)]
pub(crate) fn after_call(&self, success: bool) {
let generation = self.lock_inner().generation;
self.finish_call(generation, !success, Duration::ZERO);
}
fn generation(&self) -> u64 {
self.lock_inner().generation
}
fn finish_call(&self, generation: u64, failed: bool, elapsed: Duration) {
let mut inner = self.lock_inner();
let now = self.now();
let slow = inner.config.is_slow(elapsed);
inner.record(&self.name, now, generation, failed, slow);
}
fn cancel_call(&self, generation: u64, elapsed: Duration) {
let mut inner = self.lock_inner();
let now = self.now();
if inner.config.is_slow(elapsed) && !std::thread::panicking() {
let failed = inner.config.cancelled_call_outcome == CancelledCallOutcome::Failure;
inner.record(&self.name, now, generation, failed, true);
} else if inner.state == CircuitState::HalfOpen && inner.generation == generation {
inner.half_open_in_flight = inner.half_open_in_flight.saturating_sub(1);
}
}
pub async fn run<F, T, E>(&self, fut: F) -> Result<T, CircuitBreakerError<E>>
where
F: Future<Output = Result<T, E>>,
{
let guard = self.admit().map_err(|_| CircuitBreakerError::Open)?;
let res = fut.await;
match &res {
Ok(_) => guard.success(),
Err(_) => guard.failure(),
}
res.map_err(CircuitBreakerError::Execution)
}
pub async fn run_with_fallback<F, T, E, FB>(&self, fut: F, fallback: FB) -> Result<T, E>
where
F: Future<Output = Result<T, E>>,
FB: FnOnce(CircuitBreakerError<E>) -> Result<T, E>,
{
match self.run(fut).await {
Ok(val) => Ok(val),
Err(err) => fallback(err),
}
}
}
fn read_clock(system_clock: bool) -> Instant {
if system_clock {
crate::time::system_instant()
} else {
crate::time::ambient_instant()
}
}
pub struct CircuitBreakerGuard {
breaker: CircuitBreaker,
started: Instant,
generation: u64,
completed: bool,
}
impl CircuitBreakerGuard {
pub fn new(breaker: CircuitBreaker) -> Self {
let generation = breaker.generation();
Self::admitted(breaker, generation)
}
fn admitted(breaker: CircuitBreaker, generation: u64) -> Self {
Self {
started: breaker.now(),
generation,
breaker,
completed: false,
}
}
fn elapsed(&self) -> Duration {
self.breaker.now().saturating_duration_since(self.started)
}
pub fn success(mut self) {
self.completed = true;
self.breaker
.finish_call(self.generation, false, self.elapsed());
}
pub fn failure(mut self) {
self.completed = true;
self.breaker
.finish_call(self.generation, true, self.elapsed());
}
}
impl Drop for CircuitBreakerGuard {
fn drop(&mut self) {
if !self.completed {
self.breaker.cancel_call(self.generation, self.elapsed());
}
}
}
pub struct CircuitBreakerRegistry {
breakers: Mutex<HashMap<String, CircuitBreaker>>,
system_clock: bool,
}
impl CircuitBreakerRegistry {
pub fn new() -> Self {
Self {
breakers: Mutex::new(HashMap::new()),
system_clock: false,
}
}
fn create(&self, name: &str, config: CircuitBreakerPolicy) -> CircuitBreaker {
if self.system_clock {
CircuitBreaker::new_on_system_clock(name, config)
} else {
CircuitBreaker::new(name, config)
}
}
fn lock_breakers(&self) -> MutexGuard<'_, HashMap<String, CircuitBreaker>> {
self.breakers.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn get_or_create(&self, name: &str, config: CircuitBreakerPolicy) -> CircuitBreaker {
let mut breakers = self.lock_breakers();
breakers
.entry(name.to_owned())
.or_insert_with(|| self.create(name, config))
.clone()
}
pub fn get_or_create_with_config(
&self,
name: &str,
config: CircuitBreakerPolicy,
) -> CircuitBreaker {
let mut breakers = self.lock_breakers();
if let Some(breaker) = breakers.get(name) {
breaker.update_config(config);
breaker.clone()
} else {
let breaker = self.create(name, config);
breakers.insert(name.to_owned(), breaker.clone());
breaker
}
}
pub fn all_breakers(&self) -> Vec<CircuitBreaker> {
let breakers = self.lock_breakers();
breakers.values().cloned().collect()
}
pub fn clear(&self) {
let mut breakers = self.lock_breakers();
breakers.clear();
}
}
static REGISTRY: std::sync::OnceLock<CircuitBreakerRegistry> = std::sync::OnceLock::new();
pub fn global_registry() -> &'static CircuitBreakerRegistry {
REGISTRY.get_or_init(|| CircuitBreakerRegistry {
system_clock: true,
..CircuitBreakerRegistry::new()
})
}
pub static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Clone)]
pub struct CircuitBreakerLayer {
breaker: CircuitBreaker,
}
impl CircuitBreakerLayer {
#[must_use]
pub const fn new(breaker: CircuitBreaker) -> Self {
Self { breaker }
}
}
impl<S> tower::Layer<S> for CircuitBreakerLayer {
type Service = CircuitBreakerService<S>;
fn layer(&self, inner: S) -> Self::Service {
CircuitBreakerService {
inner,
breaker: self.breaker.clone(),
}
}
}
#[derive(Clone)]
pub struct CircuitBreakerService<S> {
inner: S,
breaker: CircuitBreaker,
}
pin_project_lite::pin_project! {
#[project = CircuitBreakerServiceFutureProj]
pub enum CircuitBreakerServiceFuture<F> {
Executing {
#[pin]
fut: F,
guard: Option<CircuitBreakerGuard>,
},
Open,
}
}
impl<F, T, E> std::future::Future for CircuitBreakerServiceFuture<F>
where
F: std::future::Future<Output = Result<T, E>>,
{
type Output = Result<T, CircuitBreakerError<E>>;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
match self.project() {
CircuitBreakerServiceFutureProj::Executing { fut, guard } => match fut.poll(cx) {
std::task::Poll::Ready(Ok(val)) => {
if let Some(g) = guard.take() {
g.success();
}
std::task::Poll::Ready(Ok(val))
}
std::task::Poll::Ready(Err(err)) => {
if let Some(g) = guard.take() {
g.failure();
}
std::task::Poll::Ready(Err(CircuitBreakerError::Execution(err)))
}
std::task::Poll::Pending => std::task::Poll::Pending,
},
CircuitBreakerServiceFutureProj::Open => {
std::task::Poll::Ready(Err(CircuitBreakerError::Open))
}
}
}
}
impl<S, Request> tower::Service<Request> for CircuitBreakerService<S>
where
S: tower::Service<Request>,
{
type Response = S::Response;
type Error = CircuitBreakerError<S::Error>;
type Future = CircuitBreakerServiceFuture<S::Future>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner
.poll_ready(cx)
.map_err(CircuitBreakerError::Execution)
}
fn call(&mut self, req: Request) -> Self::Future {
match self.breaker.admit() {
Ok(guard) => {
let fut = self.inner.call(req);
CircuitBreakerServiceFuture::Executing {
fut,
guard: Some(guard),
}
}
Err(_) => CircuitBreakerServiceFuture::Open,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn zero_half_open_trials_still_admit_one_trial() {
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
minimum_sample_count: 1,
open_duration: Duration::ZERO,
half_open_trial_count: 0,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("zero-trials", policy.clone());
assert_eq!(breaker.config().half_open_trial_count, 1);
let tripped: Result<(), _> = breaker.run(async { Err("down") }).await;
assert!(tripped.is_err());
assert_eq!(breaker.state(), CircuitState::HalfOpen);
let trial: Result<(), CircuitBreakerError<&'static str>> =
breaker.run(async { Ok(()) }).await;
assert!(trial.is_ok(), "the half-open breaker refused its trial");
assert_eq!(breaker.state(), CircuitState::Closed);
breaker.update_config(policy);
assert_eq!(breaker.config().half_open_trial_count, 1);
}
#[tokio::test]
async fn test_circuit_breaker_transitions_to_open() {
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 5,
open_duration: Duration::from_secs(60),
half_open_trial_count: 2,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("test", policy);
assert_eq!(breaker.state(), CircuitState::Closed);
for _ in 0..5 {
let res: Result<(), _> = breaker.run(async { Err("error") }).await;
assert!(matches!(res, Err(CircuitBreakerError::Execution("error"))));
}
assert_eq!(breaker.state(), CircuitState::Open);
let mut executed = false;
let res: Result<(), CircuitBreakerError<&'static str>> = breaker
.run(async {
executed = true;
Ok(())
})
.await;
assert!(matches!(res, Err(CircuitBreakerError::Open)));
assert!(!executed);
}
#[tokio::test]
async fn test_circuit_breaker_tower_service() {
use tower::{Layer, Service};
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 5,
open_duration: Duration::from_secs(60),
half_open_trial_count: 2,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("tower_test", policy);
struct DummyService;
impl tower::Service<&'static str> for DummyService {
type Response = &'static str;
type Error = &'static str;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(
&mut self,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, req: &'static str) -> Self::Future {
if req == "fail" {
std::future::ready(Err("failed"))
} else {
std::future::ready(Ok("ok"))
}
}
}
let mut svc = CircuitBreakerLayer::new(breaker.clone()).layer(DummyService);
for _ in 0..5 {
let res = svc.call("fail").await;
assert!(matches!(res, Err(CircuitBreakerError::Execution("failed"))));
}
assert_eq!(breaker.state(), CircuitState::Open);
let res = svc.call("ok").await;
assert!(matches!(res, Err(CircuitBreakerError::Open)));
}
#[test]
fn test_circuit_breaker_policy_clamps_zero_half_open_trial_count() {
let rc = crate::config::ResilienceConfig {
circuit_breaker: crate::config::CircuitBreakerConfig {
defaults: crate::config::CircuitBreakerPolicyConfig {
failure_ratio_threshold: None,
sample_window_secs: None,
minimum_sample_count: None,
open_duration_secs: None,
half_open_trial_count: Some(0),
..Default::default()
},
hosts: {
let mut m = std::collections::HashMap::new();
m.insert(
"override-zero".to_string(),
crate::config::CircuitBreakerPolicyConfig {
failure_ratio_threshold: None,
sample_window_secs: None,
minimum_sample_count: None,
open_duration_secs: None,
half_open_trial_count: Some(0),
..Default::default()
},
);
m
},
},
};
let policy_default = CircuitBreakerPolicy::from_config(&rc, "some-other-host");
assert_eq!(policy_default.half_open_trial_count, 1);
let policy_override = CircuitBreakerPolicy::from_config(&rc, "override-zero");
assert_eq!(policy_override.half_open_trial_count, 1);
}
#[tokio::test]
async fn test_circuit_breaker_tower_service_cancellation() {
use tower::{Layer, Service};
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 5,
open_duration: Duration::from_secs(60),
half_open_trial_count: 2,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("tower_cancel_test", policy);
{
let mut inner = breaker.inner.lock().unwrap();
inner.state = CircuitState::HalfOpen;
inner.half_open_in_flight = 0;
}
struct PendingService;
impl tower::Service<&'static str> for PendingService {
type Response = &'static str;
type Error = &'static str;
type Future = std::future::Pending<Result<Self::Response, Self::Error>>;
fn poll_ready(
&mut self,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, _: &'static str) -> Self::Future {
std::future::pending()
}
}
let mut svc = CircuitBreakerLayer::new(breaker.clone()).layer(PendingService);
let fut = svc.call("ok");
let in_flight_before = breaker.inner.lock().unwrap().half_open_in_flight;
assert_eq!(in_flight_before, 1);
drop(fut);
let in_flight_after = breaker.inner.lock().unwrap().half_open_in_flight;
assert_eq!(in_flight_after, 0);
}
#[tokio::test]
async fn test_circuit_breaker_clamps_zero_failure_ratio_threshold() {
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.0,
sample_window: Duration::from_secs(10),
minimum_sample_count: 5,
open_duration: Duration::from_secs(60),
half_open_trial_count: 2,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("clamp_test", policy);
let config = breaker.config();
assert!(config.failure_ratio_threshold > 0.0);
assert!(config.failure_ratio_threshold <= 1.0);
for _ in 0..5 {
let res: Result<(), CircuitBreakerError<&'static str>> =
breaker.run(async { Ok::<(), &'static str>(()) }).await;
assert!(res.is_ok());
}
assert_eq!(breaker.state(), CircuitState::Closed);
}
#[tokio::test]
async fn test_circuit_breaker_run_with_fallback() {
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 2,
open_duration: Duration::from_secs(60),
half_open_trial_count: 1,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("fallback_test", policy);
let fallback_result = breaker
.run_with_fallback(
async { Err::<&'static str, &'static str>("failed") },
|err| match err {
CircuitBreakerError::Execution(_) => Ok("fallback_success"),
CircuitBreakerError::Open => Err("wrong_error"),
},
)
.await;
assert_eq!(fallback_result, Ok("fallback_success"));
let _ = breaker
.run(async { Err::<(), &'static str>("fail1") })
.await;
let _ = breaker
.run(async { Err::<(), &'static str>("fail2") })
.await;
assert_eq!(breaker.state(), CircuitState::Open);
let fallback_result_open = breaker
.run_with_fallback(
async { Ok::<&'static str, &'static str>("won't run") },
|err| match err {
CircuitBreakerError::Open => Ok("fallback_from_open"),
CircuitBreakerError::Execution(_) => Err("wrong_error"),
},
)
.await;
assert_eq!(fallback_result_open, Ok("fallback_from_open"));
}
#[test]
fn test_circuit_breaker_recovers_from_poisoned_mutex() {
let breaker = CircuitBreaker::new("poison_test", CircuitBreakerPolicy::default());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = breaker.inner.lock().unwrap();
panic!("poison the circuit breaker mutex");
}));
assert!(result.is_err());
assert!(breaker.inner.is_poisoned());
assert_eq!(breaker.state(), CircuitState::Closed);
assert!(breaker.before_call().is_ok());
breaker.after_call(true);
assert!(breaker.failure_ratio() < f64::EPSILON);
let config = breaker.config();
breaker.update_config(config);
drop(CircuitBreakerGuard::new(breaker.clone()));
let registry = CircuitBreakerRegistry::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = registry.breakers.lock().unwrap();
panic!("poison the registry mutex");
}));
assert!(result.is_err());
assert!(registry.breakers.is_poisoned());
let b = registry.get_or_create("poison_reg", CircuitBreakerPolicy::default());
let b2 = registry.get_or_create_with_config("poison_reg", CircuitBreakerPolicy::default());
assert_eq!(b.name(), b2.name());
assert_eq!(registry.all_breakers().len(), 1);
registry.clear();
assert!(registry.all_breakers().is_empty());
}
#[test]
fn test_circuit_breaker_survives_panic_during_state_transition() {
struct PanickingSubscriber;
impl tracing::Subscriber for PanickingSubscriber {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, _: &tracing::Event<'_>) {
panic!("subscriber panic during circuit breaker transition");
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let policy = CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: Duration::from_secs(10),
minimum_sample_count: 1,
open_duration: Duration::ZERO,
half_open_trial_count: 1,
..CircuitBreakerPolicy::default()
};
let mut breaker = None;
for attempt in 1..=5 {
let candidate = CircuitBreaker::new("transition_panic_test", policy.clone());
tracing::callsite::rebuild_interest_cache();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tracing::subscriber::with_default(PanickingSubscriber, || {
candidate.after_call(false);
});
}));
if result.is_err() {
breaker = Some(candidate);
break;
}
assert!(
attempt < 5,
"the panicking subscriber never observed the transition event after {attempt} attempts"
);
}
let breaker = breaker.expect("a poisoned breaker");
assert!(breaker.inner.is_poisoned());
assert_eq!(breaker.state(), CircuitState::HalfOpen);
assert!(breaker.before_call().is_ok());
breaker.after_call(true);
assert_eq!(breaker.state(), CircuitState::Closed);
}
impl CircuitBreaker {
fn trial_view(&self) -> (CircuitState, u64) {
let inner = self.lock_inner();
(inner.state, inner.half_open_in_flight)
}
}
fn slow_policy(threshold: Duration) -> CircuitBreakerPolicy {
CircuitBreakerPolicy {
minimum_sample_count: 2,
slow_call_duration_threshold: Some(threshold),
slow_call_rate_threshold: 0.5,
..CircuitBreakerPolicy::default()
}
}
fn guard_started(breaker: &CircuitBreaker, ago: Duration) -> CircuitBreakerGuard {
let mut guard = CircuitBreakerGuard::new(breaker.clone());
guard.started = guard.started.checked_sub(ago).expect("instant in range");
guard
}
#[test]
fn slow_successes_open_the_breaker() {
let breaker = CircuitBreaker::new("slow_ok", slow_policy(Duration::from_secs(5)));
guard_started(&breaker, Duration::from_secs(10)).success();
assert_eq!(breaker.state(), CircuitState::Closed, "1 of 2 samples");
guard_started(&breaker, Duration::from_secs(10)).success();
assert_eq!(breaker.state(), CircuitState::Open);
assert!((breaker.slow_call_ratio() - 1.0).abs() < f64::EPSILON);
assert!(breaker.failure_ratio() < f64::EPSILON);
assert_eq!(breaker.slow_calls_total(), 2);
}
#[test]
fn fast_calls_are_not_slow() {
let breaker = CircuitBreaker::new("fast_ok", slow_policy(Duration::from_secs(5)));
for _ in 0..4 {
guard_started(&breaker, Duration::from_secs(1)).success();
}
assert_eq!(breaker.state(), CircuitState::Closed);
assert_eq!(breaker.slow_calls_total(), 0);
}
#[test]
fn no_threshold_turns_slow_detection_off() {
let policy = CircuitBreakerPolicy {
slow_call_duration_threshold: None,
..slow_policy(Duration::ZERO)
};
let breaker = CircuitBreaker::new("slow_off", policy);
for _ in 0..4 {
guard_started(&breaker, Duration::from_secs(61)).success();
}
assert_eq!(breaker.state(), CircuitState::Closed);
assert_eq!(breaker.slow_calls_total(), 0);
}
#[test]
fn cancelled_slow_call_counts_as_slow() {
let breaker = CircuitBreaker::new("cancel_slow", slow_policy(Duration::from_secs(5)));
drop(guard_started(&breaker, Duration::from_secs(5)));
drop(guard_started(&breaker, Duration::from_secs(30)));
assert_eq!(breaker.state(), CircuitState::Open);
assert!(breaker.failure_ratio() < f64::EPSILON, "slow, not failed");
assert_eq!(breaker.slow_calls_total(), 2);
}
#[test]
fn cancelled_slow_call_counts_as_failure_when_configured() {
let policy = CircuitBreakerPolicy {
slow_call_rate_threshold: 1.0,
minimum_sample_count: 3,
cancelled_call_outcome: CancelledCallOutcome::Failure,
..slow_policy(Duration::from_secs(5))
};
let breaker = CircuitBreaker::new("cancel_fail", policy);
guard_started(&breaker, Duration::ZERO).success();
drop(guard_started(&breaker, Duration::from_secs(30)));
assert!((breaker.failure_ratio() - 0.5).abs() < f64::EPSILON);
assert!((breaker.slow_call_ratio() - 0.5).abs() < f64::EPSILON);
drop(guard_started(&breaker, Duration::from_secs(30)));
assert_eq!(breaker.state(), CircuitState::Open, "failure ratio 2/3");
}
#[test]
fn cancelled_fast_call_counts_nothing() {
let breaker = CircuitBreaker::new("cancel_fast", slow_policy(Duration::from_secs(5)));
for _ in 0..4 {
drop(guard_started(&breaker, Duration::from_secs(1)));
}
assert_eq!(breaker.state(), CircuitState::Closed);
assert_eq!(breaker.lock_inner().window.counts(breaker.now()).total, 0);
}
#[test]
fn dropped_tower_future_counts_as_slow_after_threshold() {
use tower::{Layer, Service};
struct Hang;
impl tower::Service<()> for Hang {
type Response = ();
type Error = ();
type Future = std::future::Pending<Result<(), ()>>;
fn poll_ready(
&mut self,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), ()>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, (): ()) -> Self::Future {
std::future::pending()
}
}
let breaker = CircuitBreaker::new("tower_slow", slow_policy(Duration::from_secs(5)));
let mut svc = CircuitBreakerLayer::new(breaker.clone()).layer(Hang);
for _ in 0..2 {
let mut fut = svc.call(());
if let CircuitBreakerServiceFuture::Executing { guard, .. } = &mut fut {
let g = guard.as_mut().expect("guard");
g.started = g.started.checked_sub(Duration::from_secs(6)).unwrap();
}
drop(fut);
}
assert_eq!(breaker.state(), CircuitState::Open);
}
#[test]
fn slow_half_open_trial_opens_the_breaker_again() {
let breaker = CircuitBreaker::new("half_open_slow", slow_policy(Duration::from_secs(5)));
breaker.lock_inner().state = CircuitState::HalfOpen;
breaker.before_call().expect("trial slot");
guard_started(&breaker, Duration::from_secs(10)).success();
assert_eq!(breaker.state(), CircuitState::Open);
}
#[test]
fn slow_cancelled_half_open_trial_opens_the_breaker_again() {
let breaker = CircuitBreaker::new("half_open_cancel", slow_policy(Duration::from_secs(5)));
breaker.lock_inner().state = CircuitState::HalfOpen;
breaker.before_call().expect("trial slot");
drop(guard_started(&breaker, Duration::from_secs(10)));
let (state, in_flight) = breaker.trial_view();
assert_eq!(state, CircuitState::Open);
assert_eq!(in_flight, 0);
}
#[test]
fn new_sample_window_keeps_the_counts() {
let breaker = CircuitBreaker::new("resize", CircuitBreakerPolicy::default());
breaker.after_call(false);
breaker.after_call(true);
for window in [30, 10, 30] {
breaker.update_config(CircuitBreakerPolicy {
sample_window: Duration::from_secs(window),
..CircuitBreakerPolicy::default()
});
assert!((breaker.failure_ratio() - 0.5).abs() < f64::EPSILON);
}
assert_eq!(breaker.window_counts().total, 2);
}
#[test]
fn switching_windows_does_not_stop_the_trip() {
let policy = |secs| CircuitBreakerPolicy {
sample_window: Duration::from_secs(secs),
minimum_sample_count: 4,
..CircuitBreakerPolicy::default()
};
let breaker = CircuitBreaker::new("flip", policy(10));
for secs in [10, 20, 10, 20] {
breaker.update_config(policy(secs));
breaker.after_call(false);
}
assert_eq!(breaker.state(), CircuitState::Open);
}
#[test]
fn call_from_before_the_trip_is_not_a_half_open_trial() {
let policy = CircuitBreakerPolicy {
minimum_sample_count: 1,
half_open_trial_count: 1,
open_duration: Duration::ZERO,
..slow_policy(Duration::from_secs(5))
};
let breaker = CircuitBreaker::new("late_call", policy);
let late = guard_started(&breaker, Duration::from_secs(10));
breaker.after_call(false);
assert_eq!(breaker.state(), CircuitState::HalfOpen);
breaker.before_call().expect("trial slot");
late.success();
let (state, in_flight) = breaker.trial_view();
assert_eq!(state, CircuitState::HalfOpen, "not reopened");
assert_eq!(in_flight, 1, "the trial keeps its slot");
assert_eq!(breaker.slow_calls_total(), 1, "still a slow call");
}
#[test]
fn admission_generation_is_kept_across_a_state_change() {
let policy = CircuitBreakerPolicy {
minimum_sample_count: 1,
half_open_trial_count: 1,
open_duration: Duration::ZERO,
..slow_policy(Duration::from_secs(5))
};
let breaker = CircuitBreaker::new("admit_gap", policy);
let generation = breaker.before_call().expect("closed");
breaker.after_call(false);
assert_eq!(breaker.state(), CircuitState::HalfOpen);
breaker.before_call().expect("trial slot");
let mut late = CircuitBreakerGuard::admitted(breaker.clone(), generation);
late.started = late.started.checked_sub(Duration::from_secs(10)).unwrap();
late.success();
assert_eq!(breaker.trial_view(), (CircuitState::HalfOpen, 1));
}
#[test]
fn drop_during_panic_frees_the_slot_and_keeps_the_state() {
let breaker = CircuitBreaker::new("panic_drop", slow_policy(Duration::from_secs(5)));
breaker.lock_inner().state = CircuitState::HalfOpen;
breaker.before_call().expect("trial slot");
let guard = guard_started(&breaker, Duration::from_secs(10));
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _guard = guard;
panic!("call body panics");
}));
assert!(res.is_err());
assert_eq!(breaker.trial_view(), (CircuitState::HalfOpen, 0));
}
#[test]
fn slow_failed_call_counts_in_both_ratios() {
let breaker = CircuitBreaker::new("slow_fail", slow_policy(Duration::from_secs(5)));
guard_started(&breaker, Duration::from_secs(10)).failure();
assert!((breaker.failure_ratio() - 1.0).abs() < f64::EPSILON);
assert!((breaker.slow_call_ratio() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn host_override_sets_slow_call_keys() {
let mut rc = crate::config::ResilienceConfig::default();
rc.circuit_breaker.hosts.insert(
"api".to_owned(),
crate::config::CircuitBreakerPolicyConfig {
slow_call_rate_threshold: Some(0.25),
cancelled_call_outcome: Some(CancelledCallOutcome::Failure),
..Default::default()
},
);
let host = CircuitBreakerPolicy::from_config(&rc, "api");
assert!((host.slow_call_rate_threshold - 0.25).abs() < f64::EPSILON);
assert_eq!(host.cancelled_call_outcome, CancelledCallOutcome::Failure);
let other = CircuitBreakerPolicy::from_config(&rc, "other");
assert!((other.slow_call_rate_threshold - 1.0).abs() < f64::EPSILON);
assert_eq!(other.cancelled_call_outcome, CancelledCallOutcome::Slow);
}
#[test]
fn policy_from_config_reads_slow_call_keys() {
let mut rc = crate::config::ResilienceConfig::default();
rc.circuit_breaker.defaults.slow_call_duration_threshold_ms = Some(2500);
rc.circuit_breaker.defaults.slow_call_rate_threshold = Some(0.0);
rc.circuit_breaker.defaults.cancelled_call_outcome = Some(CancelledCallOutcome::Failure);
rc.circuit_breaker.hosts.insert(
"off".to_owned(),
crate::config::CircuitBreakerPolicyConfig {
slow_call_duration_threshold_ms: Some(0),
..Default::default()
},
);
let policy = CircuitBreakerPolicy::from_config(&rc, "any");
assert_eq!(
policy.slow_call_duration_threshold,
Some(Duration::from_millis(2500))
);
assert!(policy.slow_call_rate_threshold > 0.0, "clamped above zero");
assert_eq!(policy.cancelled_call_outcome, CancelledCallOutcome::Failure);
let off = CircuitBreakerPolicy::from_config(&rc, "off");
assert_eq!(off.slow_call_duration_threshold, None, "0 turns it off");
assert_eq!(off.cancelled_call_outcome, CancelledCallOutcome::Failure);
let defaults = CircuitBreakerPolicy::default();
assert_eq!(
defaults.slow_call_duration_threshold,
Some(Duration::from_secs(60))
);
assert!((defaults.slow_call_rate_threshold - 1.0).abs() < f64::EPSILON);
}
#[test]
fn cancelled_call_outcome_parses() {
assert_eq!("slow".parse(), Ok(CancelledCallOutcome::Slow));
assert_eq!(" failure ".parse(), Ok(CancelledCallOutcome::Failure));
assert!("Failure".parse::<CancelledCallOutcome>().is_err());
assert!("drop".parse::<CancelledCallOutcome>().is_err());
}
#[test]
fn zero_sample_window_does_not_divide_by_zero() {
let origin = Instant::now();
let mut window = SampleWindow::new(Duration::ZERO, origin);
window.record(origin, true, false);
assert_eq!(window.counts(origin).total, 1);
let later = origin + Duration::from_secs(1);
assert_eq!(window.counts(later).total, 0);
}
#[test]
fn window_skips_buckets_after_a_backward_clock_step() {
let origin = Instant::now();
let mut window = SampleWindow::new(Duration::from_secs(10), origin);
window.record(origin + Duration::from_secs(50), true, true);
assert_eq!(window.counts(origin + Duration::from_secs(1)).total, 0);
window.record(origin + Duration::from_secs(1), false, false);
assert_eq!(window.counts(origin + Duration::from_secs(50)).total, 0);
assert_eq!(window.counts(origin + Duration::from_secs(1)).total, 1);
}
#[test]
fn small_backward_step_keeps_the_window() {
let origin = Instant::now();
let mut window = SampleWindow::new(Duration::from_secs(10), origin);
window.record(origin + Duration::from_millis(5_100), true, false);
window.record(origin + Duration::from_millis(4_900), true, false);
let now = origin + Duration::from_millis(5_200);
assert_eq!(window.counts(now).total, 2);
assert_eq!(window.counts(now).failed, 2);
}
struct ReferenceWindow {
origin: Instant,
width: Duration,
samples: Vec<(Instant, bool, bool)>,
}
impl ReferenceWindow {
fn slice(&self, t: Instant) -> u128 {
t.duration_since(self.origin).as_nanos() / self.width.as_nanos().max(1)
}
fn counts(&self, now: Instant) -> WindowCounts {
let current = self.slice(now);
let mut counts = WindowCounts::default();
for &(t, failed, slow) in &self.samples {
if current - self.slice(t) < WINDOW_BUCKETS as u128 {
counts.total += 1;
counts.failed += u64::from(failed);
counts.slow += u64::from(slow);
}
}
counts
}
fn younger_than(&self, now: Instant, age: Duration) -> u64 {
self.samples
.iter()
.filter(|(t, _, _)| now.duration_since(*t) < age)
.count() as u64
}
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn ring_window_agrees_with_reference(
window_ms in 1_u64..30_000,
steps in proptest::collection::vec(
(0_u64..4_000, proptest::bool::ANY, proptest::bool::ANY, proptest::bool::ANY),
1..300,
),
) {
let origin = Instant::now();
let window = Duration::from_millis(window_ms);
let mut ring = SampleWindow::new(window, origin);
let width = Duration::from_nanos(
u64::try_from(window.as_nanos() / WINDOW_BUCKETS as u128).unwrap(),
);
let mut reference = ReferenceWindow {
origin,
width,
samples: Vec::new(),
};
let mut now = origin;
for (advance_ms, record, failed, slow) in steps {
now += Duration::from_millis(advance_ms);
if record {
ring.record(now, failed, slow);
reference.samples.push((now, failed, slow));
}
let got = ring.counts(now);
let want = reference.counts(now);
proptest::prop_assert_eq!(got, want);
proptest::prop_assert_eq!(got.failure_ratio().to_bits(), want.failure_ratio().to_bits());
proptest::prop_assert_eq!(got.slow_call_ratio().to_bits(), want.slow_call_ratio().to_bits());
proptest::prop_assert!(reference.younger_than(now, width * 9) <= got.total);
proptest::prop_assert!(got.total <= reference.younger_than(now, window));
}
}
}
const _: () = assert!(std::mem::size_of::<SampleWindow>() <= 512);
#[test]
fn ring_window_memory_does_not_grow_with_rate() {
let origin = Instant::now();
let mut window = SampleWindow::new(Duration::from_secs(10), origin);
for i in 0..1_000_000_u64 {
window.record(origin + Duration::from_micros(i * 10), i % 3 == 0, false);
}
assert_eq!(window.buckets.len(), WINDOW_BUCKETS);
let last = origin + Duration::from_micros(9_999_990);
assert_eq!(window.counts(last).total, 1_000_000);
let ten = origin + Duration::from_secs(10);
let counts = window.counts(ten);
assert_eq!(counts.total, 900_000);
assert_eq!(counts.failed, 300_000);
}
}