use super::*;
pub(crate) const BREAKER_FAILURE_THRESHOLD: u32 = 4;
pub(crate) const BREAKER_COOLDOWN_SECS: u64 = 30;
pub(crate) static FAILOVER_TOTAL: AtomicU64 = AtomicU64::new(0);
pub(crate) static FAILURES_CONSECUTIVE: AtomicU64 = AtomicU64::new(0);
pub(crate) static BREAKER_OPEN_UNTIL_EPOCH_MS: AtomicU64 = AtomicU64::new(0);
pub(crate) static LAST_FAILURE_EPOCH_MS: AtomicU64 = AtomicU64::new(0);
pub(crate) fn epoch_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Serialize)]
pub struct ProviderFailoverSnapshot {
pub total_fallbacks: u64,
pub consecutive_failures: u64,
pub breaker_open: bool,
pub last_failure_epoch_ms: Option<u64>,
}
pub fn provider_failover_snapshot() -> ProviderFailoverSnapshot {
let last = LAST_FAILURE_EPOCH_MS.load(Ordering::Relaxed);
ProviderFailoverSnapshot {
total_fallbacks: FAILOVER_TOTAL.load(Ordering::Relaxed),
consecutive_failures: FAILURES_CONSECUTIVE.load(Ordering::Relaxed),
breaker_open: BREAKER_OPEN_UNTIL_EPOCH_MS.load(Ordering::Relaxed) > epoch_ms(),
last_failure_epoch_ms: if last == 0 { None } else { Some(last) },
}
}
pub(crate) fn note_fallback_used() {
FAILOVER_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub(crate) struct CircuitBreaker {
failures: AtomicU32,
open_until: Mutex<Option<Instant>>,
threshold: u32,
cooldown: Duration,
}
impl CircuitBreaker {
pub(crate) fn new(threshold: u32, cooldown: Duration) -> Self {
Self {
failures: AtomicU32::new(0),
open_until: Mutex::new(None),
threshold,
cooldown,
}
}
pub(crate) fn is_open(&self, now: Instant) -> bool {
match *self.open_until.lock().unwrap_or_else(|p| p.into_inner()) {
Some(until) => now < until,
None => false,
}
}
pub(crate) fn record_success(&self) {
self.failures.store(0, Ordering::Relaxed);
if let Ok(mut guard) = self.open_until.lock() {
*guard = None;
}
FAILURES_CONSECUTIVE.store(0, Ordering::Relaxed);
}
pub(crate) fn record_failure(&self, now: Instant) {
let n = self.failures.fetch_add(1, Ordering::Relaxed) + 1;
FAILURES_CONSECUTIVE.fetch_add(1, Ordering::Relaxed);
LAST_FAILURE_EPOCH_MS.store(epoch_ms(), Ordering::Relaxed);
if n >= self.threshold {
if let Ok(mut guard) = self.open_until.lock() {
*guard = Some(now + self.cooldown);
}
BREAKER_OPEN_UNTIL_EPOCH_MS.fetch_max(
epoch_ms() + self.cooldown.as_millis() as u64,
Ordering::Relaxed,
);
}
}
}
pub struct ResilientProvider {
inner: Box<dyn LlmProvider>,
fallback_model: String,
breaker: CircuitBreaker,
}
impl ResilientProvider {
pub(crate) fn new(inner: Box<dyn LlmProvider>, fallback_model: String) -> Self {
Self {
inner,
fallback_model,
breaker: CircuitBreaker::new(
BREAKER_FAILURE_THRESHOLD,
Duration::from_secs(BREAKER_COOLDOWN_SECS),
),
}
}
async fn call(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
model_override: Option<&str>,
) -> Result<MessagesResponse, MicroClawError> {
let now = Instant::now();
if model_override.is_none() && self.breaker.is_open(now) {
warn!(
"LLM circuit breaker open; routing to fallback model {}",
self.fallback_model
);
note_fallback_used();
return self
.inner
.send_message_with_model(system, messages, tools, Some(&self.fallback_model))
.await;
}
match self
.inner
.send_message_with_model(system, messages.clone(), tools.clone(), model_override)
.await
{
Ok(r) => {
self.breaker.record_success();
Ok(r)
}
Err(e) => {
self.breaker.record_failure(now);
if model_override != Some(self.fallback_model.as_str()) {
warn!(
"primary LLM call failed ({e}); retrying with fallback model {}",
self.fallback_model
);
note_fallback_used();
match self
.inner
.send_message_with_model(
system,
messages,
tools,
Some(&self.fallback_model),
)
.await
{
Ok(r) => Ok(r),
Err(e2) => {
warn!("fallback model also failed: {e2}");
Err(e)
}
}
} else {
Err(e)
}
}
}
}
async fn call_stream(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
text_tx: Option<&UnboundedSender<String>>,
model_override: Option<&str>,
) -> Result<MessagesResponse, MicroClawError> {
let now = Instant::now();
if model_override.is_none() && self.breaker.is_open(now) {
warn!(
"LLM circuit breaker open; streaming via fallback model {}",
self.fallback_model
);
note_fallback_used();
return self
.inner
.send_message_stream_with_model(
system,
messages,
tools,
text_tx,
Some(&self.fallback_model),
)
.await;
}
match self
.inner
.send_message_stream_with_model(system, messages, tools, text_tx, model_override)
.await
{
Ok(r) => {
self.breaker.record_success();
Ok(r)
}
Err(e) => {
self.breaker.record_failure(now);
Err(e)
}
}
}
}
#[async_trait]
impl LlmProvider for ResilientProvider {
async fn send_message(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
) -> Result<MessagesResponse, MicroClawError> {
self.call(system, messages, tools, None).await
}
async fn send_message_with_model(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
model_override: Option<&str>,
) -> Result<MessagesResponse, MicroClawError> {
self.call(system, messages, tools, model_override).await
}
async fn send_message_stream(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
text_tx: Option<&UnboundedSender<String>>,
) -> Result<MessagesResponse, MicroClawError> {
self.call_stream(system, messages, tools, text_tx, None)
.await
}
async fn send_message_stream_with_model(
&self,
system: &str,
messages: Vec<Message>,
tools: Option<Vec<ToolDefinition>>,
text_tx: Option<&UnboundedSender<String>>,
model_override: Option<&str>,
) -> Result<MessagesResponse, MicroClawError> {
self.call_stream(system, messages, tools, text_tx, model_override)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(unused_imports)]
use crate::llm::test_prelude::*;
#[test]
pub(crate) fn test_circuit_breaker_opens_and_recovers() {
let cb = CircuitBreaker::new(2, Duration::from_secs(60));
let t0 = Instant::now();
assert!(!cb.is_open(t0));
cb.record_failure(t0);
assert!(!cb.is_open(t0), "one failure below threshold must not open");
cb.record_failure(t0);
assert!(cb.is_open(t0), "threshold failures must open the breaker");
assert!(
cb.is_open(t0 + Duration::from_secs(59)),
"open during cooldown"
);
assert!(
!cb.is_open(t0 + Duration::from_secs(61)),
"closed (half-open probe) after cooldown"
);
cb.record_success();
assert!(!cb.is_open(t0));
}
}