use crate::utils::clock::Instant;
use std::sync::Mutex;
use std::time::Duration;
pub const DEFAULT_FAILURE_THRESHOLD: u32 = 3;
pub const DEFAULT_COOLDOWN: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakerState {
Closed,
Open,
HalfOpen,
}
impl std::fmt::Display for BreakerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BreakerState::Closed => write!(f, "closed"),
BreakerState::Open => write!(f, "open"),
BreakerState::HalfOpen => write!(f, "half-open"),
}
}
}
#[derive(Debug, Clone)]
pub struct BreakerStatus {
pub state: BreakerState,
pub consecutive_failures: u32,
pub retry_in: Option<Duration>,
pub trips: u64,
pub last_error: Option<String>,
}
impl BreakerStatus {
pub fn is_open(&self) -> bool {
self.state == BreakerState::Open
}
pub fn summary(&self) -> String {
match self.state {
BreakerState::Closed => "LLM backend reachable".to_string(),
BreakerState::HalfOpen => format!(
"LLM backend circuit breaker half-open after {} trip(s): next request is a probe",
self.trips
),
BreakerState::Open => format!(
"LLM backend circuit breaker OPEN after {} consecutive transport failure(s); \
failing fast for another {}s (last error: {})",
self.consecutive_failures,
self.retry_in.map(|d| d.as_secs()).unwrap_or(0),
self.last_error.as_deref().unwrap_or("unknown")
),
}
}
}
#[derive(Debug)]
struct Inner {
consecutive_failures: u32,
opened_at: Option<Instant>,
trips: u64,
last_error: Option<String>,
generation: u64,
probe_in_flight: bool,
}
#[derive(Debug)]
pub struct CircuitBreaker {
failure_threshold: u32,
cooldown: Duration,
inner: Mutex<Inner>,
}
impl Default for CircuitBreaker {
fn default() -> Self {
Self::new(DEFAULT_FAILURE_THRESHOLD, DEFAULT_COOLDOWN)
}
}
impl CircuitBreaker {
pub fn new(failure_threshold: u32, cooldown: Duration) -> Self {
Self {
failure_threshold: failure_threshold.max(1),
cooldown,
inner: Mutex::new(Inner {
consecutive_failures: 0,
opened_at: None,
trips: 0,
last_error: None,
generation: 0,
probe_in_flight: false,
}),
}
}
pub fn acquire(&self) -> Result<BreakerPermit<'_>, BreakerOpen> {
let mut inner = self.lock();
let probe = if let Some(opened_at) = inner.opened_at {
let elapsed = opened_at.elapsed();
if inner.probe_in_flight || elapsed < self.cooldown {
return Err(BreakerOpen {
consecutive_failures: inner.consecutive_failures,
retry_in: self.cooldown.saturating_sub(elapsed),
last_error: inner.last_error.clone(),
});
}
inner.probe_in_flight = true;
true
} else {
false
};
Ok(BreakerPermit {
breaker: self,
generation: inner.generation,
probe,
completed: false,
})
}
pub fn record_success(&self) {
let mut inner = self.lock();
inner.consecutive_failures = 0;
inner.opened_at = None;
inner.last_error = None;
inner.probe_in_flight = false;
inner.generation = inner.generation.wrapping_add(1);
}
pub fn record_failure(&self, error: &str) -> bool {
let mut inner = self.lock();
inner.consecutive_failures = inner.consecutive_failures.saturating_add(1);
inner.last_error = Some(crate::utils::truncate_str(error, 4096).to_owned());
if inner.consecutive_failures >= self.failure_threshold
&& (inner.opened_at.is_none() || inner.probe_in_flight)
{
inner.opened_at = Some(Instant::now());
inner.trips = inner.trips.saturating_add(1);
inner.probe_in_flight = false;
inner.generation = inner.generation.wrapping_add(1);
return true;
}
false
}
pub fn is_open(&self) -> bool {
self.status().is_open()
}
pub fn status(&self) -> BreakerStatus {
let inner = self.lock();
let (state, retry_in) = match inner.opened_at {
None if inner.consecutive_failures >= self.failure_threshold => {
(BreakerState::HalfOpen, None)
}
None => (BreakerState::Closed, None),
Some(opened_at) => {
let elapsed = opened_at.elapsed();
if elapsed < self.cooldown {
(BreakerState::Open, Some(self.cooldown - elapsed))
} else {
(BreakerState::HalfOpen, None)
}
}
};
BreakerStatus {
state,
consecutive_failures: inner.consecutive_failures,
retry_in,
trips: inner.trips,
last_error: inner.last_error.clone(),
}
}
pub fn reset(&self) {
self.record_success();
}
pub fn failure_threshold(&self) -> u32 {
self.failure_threshold
}
pub fn cooldown(&self) -> Duration {
self.cooldown
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
}
#[must_use = "hold the lease for the request and record its outcome"]
#[derive(Debug)]
pub struct BreakerPermit<'a> {
breaker: &'a CircuitBreaker,
generation: u64,
probe: bool,
completed: bool,
}
impl BreakerPermit<'_> {
pub fn record_success(mut self) {
let mut inner = self.breaker.lock();
if inner.generation == self.generation {
inner.consecutive_failures = 0;
inner.opened_at = None;
inner.last_error = None;
inner.probe_in_flight = false;
if self.probe {
inner.generation = inner.generation.wrapping_add(1);
}
}
self.completed = true;
}
pub fn record_failure(mut self, error: &str) -> bool {
let mut inner = self.breaker.lock();
let mut tripped = false;
if inner.generation == self.generation {
inner.consecutive_failures = inner.consecutive_failures.saturating_add(1);
inner.last_error = Some(crate::utils::truncate_str(error, 4096).to_owned());
if inner.consecutive_failures >= self.breaker.failure_threshold {
inner.opened_at = Some(Instant::now());
inner.probe_in_flight = false;
inner.trips = inner.trips.saturating_add(1);
inner.generation = inner.generation.wrapping_add(1);
tripped = true;
}
}
self.completed = true;
tripped
}
}
impl Drop for BreakerPermit<'_> {
fn drop(&mut self) {
if self.probe && !self.completed {
let mut inner = self.breaker.lock();
if inner.generation == self.generation && inner.probe_in_flight {
inner.probe_in_flight = false;
inner.opened_at = Some(Instant::now());
inner.generation = inner.generation.wrapping_add(1);
}
}
}
}
#[derive(Debug, Clone)]
pub struct BreakerOpen {
pub consecutive_failures: u32,
pub retry_in: Duration,
pub last_error: Option<String>,
}
impl std::fmt::Display for BreakerOpen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"✗ LLM backend is unreachable: circuit breaker open after {} consecutive \
transport failure(s). Failing fast instead of waiting for another timeout; the \
next probe is in {}s.{}",
self.consecutive_failures,
self.retry_in.as_secs().max(1),
self.last_error
.as_deref()
.map(|e| format!("\n Last error: {}", e))
.unwrap_or_default()
)
}
}
impl std::error::Error for BreakerOpen {}
pub fn is_transport_failure(error: &anyhow::Error) -> bool {
let text = error
.chain()
.map(|cause| cause.to_string())
.collect::<Vec<_>>()
.join(": ")
.to_lowercase();
const APPLICATION_ERRORS: &[&str] = &[
"model not found",
"authentication failed",
"rate limited",
"failed to parse",
"agent-queue",
];
if APPLICATION_ERRORS.iter().any(|m| text.contains(m)) {
return false;
}
const TRANSPORT_ERRORS: &[&str] = &[
"timed out",
"timeout",
"connection",
"refused",
"cannot connect",
"failed to connect",
"error sending request",
"request failed",
"dns error",
"broken pipe",
"network is unreachable",
"no route to host",
"os error 61", "os error 111", ];
TRANSPORT_ERRORS.iter().any(|m| text.contains(m))
}