use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackState {
Primary = 0,
Fallback = 1,
Recovering = 2,
}
impl From<u8> for FallbackState {
fn from(value: u8) -> Self {
match value {
1 => FallbackState::Fallback,
2 => FallbackState::Recovering,
_ => FallbackState::Primary,
}
}
}
#[derive(Debug, Clone)]
pub struct AttemptRecord {
failed_at: Instant,
reason: Option<String>,
}
impl AttemptRecord {
#[must_use]
pub fn new(reason: impl Into<String>) -> Self {
Self {
failed_at: Instant::now(),
reason: Some(reason.into()),
}
}
#[must_use]
pub fn anonymous() -> Self {
Self {
failed_at: Instant::now(),
reason: None,
}
}
#[must_use]
pub fn with_reason(mut self, reason: Option<String>) -> Self {
self.reason = reason;
self
}
#[must_use]
pub fn failed_at(&self) -> Instant {
self.failed_at
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
self.reason.as_deref()
}
}
#[derive(Debug, Clone)]
pub struct FallbackEntry {
name: String,
available: bool,
attempts: Vec<AttemptRecord>,
max_fail_count: usize,
}
impl FallbackEntry {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
available: true,
attempts: Vec::new(),
max_fail_count: 2,
}
}
#[must_use]
pub fn with_max_fail_count(mut self, max_fail_count: usize) -> Self {
let new_max = max_fail_count.max(1);
while self.attempts.len() < new_max && self.failed() {
self.attempts.push(AttemptRecord::anonymous());
}
self.max_fail_count = new_max;
self
}
#[must_use]
pub fn new_failed(name: impl Into<String>) -> Self {
Self {
name: name.into(),
available: true,
attempts: vec![AttemptRecord::anonymous(), AttemptRecord::anonymous()],
max_fail_count: 2,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn failed(&self) -> bool {
!self.available || self.attempts.len() >= self.max_fail_count
}
#[must_use]
pub fn max_fail_count(&self) -> usize {
self.max_fail_count
}
#[must_use]
pub fn available(&self) -> bool {
self.available
}
pub fn set_available(&mut self, available: bool) {
self.available = available;
}
#[must_use]
pub fn attempt_count(&self) -> usize {
self.attempts.len()
}
#[must_use]
pub fn attempts(&self) -> &[AttemptRecord] {
&self.attempts
}
pub fn record_attempt(&mut self, reason: impl Into<String>) {
self.attempts.push(AttemptRecord::new(reason));
}
pub fn record_attempt_anonymous(&mut self) {
self.attempts.push(AttemptRecord::anonymous());
}
pub fn clear_attempts(&mut self) {
self.attempts.clear();
}
}
#[derive(Debug, Clone)]
pub struct FallbackConfig {
pub trip_threshold: usize,
pub recovery_timeout: Duration,
pub recovery_successes_needed: usize,
pub max_fail_count: usize,
}
impl Default for FallbackConfig {
fn default() -> Self {
Self {
trip_threshold: 3,
recovery_timeout: Duration::from_secs(60),
recovery_successes_needed: 2,
max_fail_count: 2,
}
}
}
#[derive(Default)]
struct FallbackInner {
original_model: Option<String>,
fallback_models: Vec<FallbackEntry>,
active_fallback: Option<String>,
fallback_switched_at: Option<Instant>,
}
pub struct FallbackManager {
fallback_threshold: usize,
primary_resume_threshold: usize,
default_max_fail_count: usize,
consecutive_failures: AtomicUsize,
fallback_activated: AtomicBool,
fallback_state: AtomicU8,
primary_success_count: AtomicUsize,
inner: Mutex<FallbackInner>,
recovery_timeout: Duration,
}
impl FallbackManager {
#[must_use]
pub fn new(fallback_threshold: usize, primary_resume_threshold: usize) -> Self {
Self {
fallback_threshold,
primary_resume_threshold,
default_max_fail_count: 2,
consecutive_failures: AtomicUsize::new(0),
fallback_activated: AtomicBool::new(false),
fallback_state: AtomicU8::new(FallbackState::Primary as u8),
primary_success_count: AtomicUsize::new(0),
inner: Mutex::new(FallbackInner::default()),
recovery_timeout: Duration::from_secs(60),
}
}
#[must_use]
pub fn with_config(mut self, config: &FallbackConfig) -> Self {
self.fallback_threshold = config.trip_threshold;
self.primary_resume_threshold = config.recovery_successes_needed;
self.default_max_fail_count = config.max_fail_count;
self.recovery_timeout = config.recovery_timeout;
self
}
#[must_use]
pub fn with_recovery_timeout(mut self, recovery_timeout: Duration) -> Self {
self.recovery_timeout = recovery_timeout;
self
}
#[must_use]
pub fn recovery_timeout(&self) -> Duration {
self.recovery_timeout
}
#[must_use]
pub fn new_with_fallback(original_model: String, fallback_threshold: usize) -> Self {
let mgr = Self::new(fallback_threshold, 2);
if let Ok(mut inner) = mgr.inner.lock() {
inner.original_model = Some(original_model);
inner.fallback_switched_at = Some(Instant::now());
}
mgr.fallback_activated.store(true, Ordering::Relaxed);
mgr.consecutive_failures.store(0, Ordering::Relaxed);
mgr.fallback_state
.store(FallbackState::Fallback as u8, Ordering::Relaxed);
mgr
}
pub fn for_model(primary_model: impl Into<String>) -> Self {
let mgr = Self::new(3, 2);
if let Ok(mut inner) = mgr.inner.lock() {
inner.original_model = Some(primary_model.into());
}
mgr
}
pub fn state(&self) -> FallbackState {
FallbackState::from(self.fallback_state.load(Ordering::Relaxed))
}
pub fn is_using_fallback(&self) -> bool {
matches!(self.state(), FallbackState::Fallback)
}
pub fn is_fallback_active(&self) -> bool {
self.fallback_activated.load(Ordering::Relaxed)
}
pub fn consecutive_failures(&self) -> usize {
self.consecutive_failures.load(Ordering::Relaxed)
}
pub fn original_model(&self) -> Option<String> {
self.inner
.lock()
.ok()
.and_then(|i| i.original_model.clone())
}
pub fn set_original_model(&self, model: String) {
if let Ok(mut inner) = self.inner.lock() {
inner.original_model = Some(model);
}
}
pub fn fallback_switched_at(&self) -> Option<Instant> {
self.inner.lock().ok().and_then(|i| i.fallback_switched_at)
}
pub fn active_model(&self) -> Option<String> {
match self.state() {
FallbackState::Primary | FallbackState::Recovering => self.original_model(),
FallbackState::Fallback => self.fallback_model().or_else(|| self.original_model()),
}
}
pub fn set_fallback_model(&self, model: impl Into<String>) {
if let Ok(mut inner) = self.inner.lock() {
inner.fallback_models.clear();
inner
.fallback_models
.push(FallbackEntry::new(model).with_max_fail_count(self.default_max_fail_count));
}
self.recompute_active_fallback();
}
pub fn fallback_model(&self) -> Option<String> {
self.inner
.lock()
.ok()
.and_then(|i| i.active_fallback.clone())
}
pub fn fallback_models(&self) -> Vec<String> {
self.inner
.lock()
.ok()
.map(|i| i.fallback_models.iter().map(|e| e.name.clone()).collect())
.unwrap_or_default()
}
pub fn add_fallback_model(&self, model: impl Into<String>) {
if let Ok(mut inner) = self.inner.lock() {
inner
.fallback_models
.push(FallbackEntry::new(model).with_max_fail_count(self.default_max_fail_count));
}
self.recompute_active_fallback();
}
pub fn insert_fallback_model(&self, index: usize, model: impl Into<String>) {
if let Ok(mut inner) = self.inner.lock() {
let entry = FallbackEntry::new(model).with_max_fail_count(self.default_max_fail_count);
if index >= inner.fallback_models.len() {
inner.fallback_models.push(entry);
} else {
inner.fallback_models.insert(index, entry);
}
}
self.recompute_active_fallback();
}
pub fn remove_fallback_model(&self, model: &str) -> bool {
let removed = if let Ok(mut inner) = self.inner.lock() {
if let Some(pos) = inner.fallback_models.iter().position(|x| x.name == model) {
inner.fallback_models.remove(pos);
true
} else {
false
}
} else {
false
};
if removed {
self.recompute_active_fallback();
}
removed
}
pub fn set_fallback_models(&self, models: Vec<String>) {
let max_fc = self.default_max_fail_count;
if let Ok(mut inner) = self.inner.lock() {
inner.fallback_models = models
.into_iter()
.map(|name| FallbackEntry::new(name).with_max_fail_count(max_fc))
.collect();
}
self.recompute_active_fallback();
}
pub fn mark_fallback_failed(&self, model: &str) -> bool {
let found = if let Ok(mut inner) = self.inner.lock() {
if let Some(entry) = inner.fallback_models.iter_mut().find(|e| e.name == model) {
entry.record_attempt("marked_failed");
true
} else {
false
}
} else {
false
};
if found {
self.recompute_active_fallback();
}
found
}
pub fn clear_fallback_failed(&self, model: &str) -> bool {
let found = if let Ok(mut inner) = self.inner.lock() {
if let Some(entry) = inner.fallback_models.iter_mut().find(|e| e.name == model) {
entry.clear_attempts();
true
} else {
false
}
} else {
false
};
if found {
self.recompute_active_fallback();
}
found
}
pub fn clear_all_fallback_failed(&self) {
if let Ok(mut inner) = self.inner.lock() {
for entry in &mut inner.fallback_models {
entry.clear_attempts();
}
}
self.recompute_active_fallback();
}
pub fn failed_fallbacks(&self) -> Vec<String> {
self.inner
.lock()
.ok()
.map(|i| {
i.fallback_models
.iter()
.filter(|e| e.failed())
.map(|e| e.name.clone())
.collect()
})
.unwrap_or_default()
}
pub fn available_fallbacks(&self) -> Vec<String> {
self.inner
.lock()
.ok()
.map(|i| {
i.fallback_models
.iter()
.filter(|e| !e.failed())
.map(|e| e.name.clone())
.collect()
})
.unwrap_or_default()
}
pub fn fallback_entry(&self, name: &str) -> Option<FallbackEntry> {
self.inner
.lock()
.ok()
.and_then(|i| i.fallback_models.iter().find(|e| e.name == name).cloned())
}
pub fn set_fallback_available(&self, model: &str, available: bool) -> bool {
let found = if let Ok(mut inner) = self.inner.lock() {
if let Some(entry) = inner.fallback_models.iter_mut().find(|e| e.name == model) {
entry.set_available(available);
true
} else {
false
}
} else {
false
};
if found {
self.recompute_active_fallback();
}
found
}
pub fn record_api_failure(&self) -> bool {
let failures = self
.consecutive_failures
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
if failures >= self.fallback_threshold && !self.fallback_activated.load(Ordering::Relaxed) {
warn!(
consecutive_failures = failures,
threshold = self.fallback_threshold,
"Fallback threshold reached"
);
self.fallback_activated.store(true, Ordering::Relaxed);
true
} else {
false
}
}
pub fn record_failure(&self) -> bool {
self.record_api_failure()
}
pub fn reset_failure_counter(&self) {
self.consecutive_failures.store(0, Ordering::Relaxed);
}
pub fn record_model_success(&self) {
match self.state() {
FallbackState::Primary => {
self.consecutive_failures.store(0, Ordering::Relaxed);
}
FallbackState::Fallback => {
}
FallbackState::Recovering => {
let successes = self
.primary_success_count
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
debug!(
successes,
threshold = self.primary_resume_threshold,
"Primary model success during recovery test"
);
if successes >= self.primary_resume_threshold {
self.transition_to_primary();
}
}
}
}
pub fn record_success(&self) {
self.record_model_success();
}
pub fn record_model_failure(&self) -> bool {
match self.state() {
FallbackState::Primary => {
let failures = self
.consecutive_failures
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
if failures >= self.fallback_threshold {
self.transition_to_fallback();
return true;
}
false
}
FallbackState::Fallback => {
let fb_name = self
.fallback_model()
.unwrap_or_else(|| "unknown".to_string());
warn!(
"Fallback model \"{fb_name}\" also experiencing failures; consider calling mark_fallback_failed(\"{fb_name}\") to skip it"
);
false
}
FallbackState::Recovering => {
warn!("Primary model failed during recovery test, staying on fallback");
self.transition_to_fallback();
false
}
}
}
pub fn should_try_resume_primary(&self, min_fallback_duration: Duration) -> bool {
if self.state() != FallbackState::Fallback {
return false;
}
if let Some(switched_at) = self.fallback_switched_at() {
switched_at.elapsed() >= min_fallback_duration
} else {
false
}
}
pub fn transition_to_fallback(&self) {
self.fallback_state
.store(FallbackState::Fallback as u8, Ordering::Relaxed);
self.fallback_activated.store(true, Ordering::Relaxed);
if let Ok(mut inner) = self.inner.lock() {
inner.fallback_switched_at = Some(Instant::now());
}
self.primary_success_count.store(0, Ordering::Relaxed);
info!("Circuit breaker: transitioned to Fallback state");
}
pub fn transition_to_recovering(&self) {
self.fallback_state
.store(FallbackState::Recovering as u8, Ordering::Relaxed);
self.primary_success_count.store(0, Ordering::Relaxed);
info!("Circuit breaker: transitioned to Recovering state (testing primary)");
}
pub fn transition_to_primary(&self) {
self.fallback_state
.store(FallbackState::Primary as u8, Ordering::Relaxed);
if let Ok(mut inner) = self.inner.lock() {
inner.fallback_switched_at = None;
}
self.primary_success_count.store(0, Ordering::Relaxed);
self.consecutive_failures.store(0, Ordering::Relaxed);
self.fallback_activated.store(false, Ordering::Relaxed);
self.clear_all_fallback_failed();
info!("Circuit breaker: transitioned to Primary state (primary model recovered)");
}
pub fn reset(&self) {
self.fallback_state
.store(FallbackState::Primary as u8, Ordering::Relaxed);
self.consecutive_failures.store(0, Ordering::Relaxed);
self.primary_success_count.store(0, Ordering::Relaxed);
self.fallback_activated.store(false, Ordering::Relaxed);
if let Ok(mut inner) = self.inner.lock() {
inner.fallback_switched_at = None;
}
self.clear_all_fallback_failed();
}
fn recompute_active_fallback(&self) {
let active = self.inner.lock().ok().and_then(|i| {
i.fallback_models
.iter()
.find(|e| !e.failed())
.map(|e| e.name.clone())
});
if let Ok(mut inner) = self.inner.lock() {
inner.active_fallback = active;
}
}
}
impl Default for FallbackManager {
fn default() -> Self {
Self::new(3, 2)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let mgr = FallbackManager::new(3, 2);
assert_eq!(mgr.state(), FallbackState::Primary);
assert!(!mgr.is_using_fallback());
assert!(!mgr.is_fallback_active());
assert_eq!(mgr.consecutive_failures(), 0);
}
#[test]
fn test_failure_threshold() {
let mgr = FallbackManager::new(3, 2);
assert!(!mgr.record_api_failure()); assert!(!mgr.record_api_failure()); assert!(mgr.record_api_failure()); }
#[test]
fn test_model_failure_triggers_fallback() {
let mgr = FallbackManager::new(3, 2);
assert!(!mgr.record_model_failure()); assert!(!mgr.record_model_failure()); assert!(mgr.record_model_failure()); assert_eq!(mgr.state(), FallbackState::Fallback);
}
#[test]
fn test_recovery() {
let mgr = FallbackManager::new(3, 2);
for _ in 0..3 {
mgr.record_model_failure();
}
assert_eq!(mgr.state(), FallbackState::Fallback);
mgr.transition_to_recovering();
assert_eq!(mgr.state(), FallbackState::Recovering);
mgr.record_model_success(); mgr.record_model_success(); assert_eq!(mgr.state(), FallbackState::Primary);
}
#[test]
fn test_recovery_failure_goes_back_to_fallback() {
let mgr = FallbackManager::new(3, 2);
for _ in 0..3 {
mgr.record_model_failure();
}
mgr.transition_to_recovering();
mgr.record_model_failure(); assert_eq!(mgr.state(), FallbackState::Fallback);
}
#[test]
fn test_should_try_resume_primary() {
let mgr = FallbackManager::new(3, 2);
assert!(!mgr.should_try_resume_primary(Duration::from_secs(10)));
for _ in 0..3 {
mgr.record_model_failure();
}
assert!(!mgr.should_try_resume_primary(Duration::from_secs(3600)));
assert!(mgr.should_try_resume_primary(Duration::from_secs(0)));
}
#[test]
fn test_new_with_fallback() {
let mgr = FallbackManager::new_with_fallback("llm-70b".into(), 3);
assert!(mgr.is_fallback_active());
assert!(mgr.is_using_fallback());
assert_eq!(mgr.original_model(), Some("llm-70b".to_string()));
}
#[test]
fn test_reset() {
let mgr = FallbackManager::new(3, 2);
for _ in 0..3 {
mgr.record_model_failure();
}
assert_eq!(mgr.state(), FallbackState::Fallback);
mgr.reset();
assert_eq!(mgr.state(), FallbackState::Primary);
assert!(!mgr.is_fallback_active());
assert_eq!(mgr.consecutive_failures(), 0);
}
#[test]
fn test_api_failure_does_not_retrip() {
let mgr = FallbackManager::new(3, 2);
for _ in 0..3 {
mgr.record_api_failure();
}
mgr.transition_to_fallback();
mgr.fallback_activated.store(true, Ordering::Relaxed);
assert!(!mgr.record_api_failure());
}
#[test]
fn test_record_success_resets_on_primary() {
let mgr = FallbackManager::new(3, 2);
mgr.record_api_failure();
mgr.record_api_failure();
assert_eq!(mgr.consecutive_failures(), 2);
mgr.record_model_success();
assert_eq!(mgr.consecutive_failures(), 0);
}
#[test]
fn test_for_model() {
let mgr = FallbackManager::for_model("llm-70b");
assert_eq!(mgr.original_model(), Some("llm-70b".to_string()));
assert_eq!(mgr.state(), FallbackState::Primary);
}
#[test]
fn test_concurrent_access() {
use std::sync::Arc;
use std::thread;
let mgr = Arc::new(FallbackManager::new(3, 2));
let mut handles = Vec::new();
for _ in 0..10 {
let mgr = Arc::clone(&mgr);
handles.push(thread::spawn(move || {
mgr.record_api_failure();
mgr.record_model_success();
mgr.state();
mgr.consecutive_failures();
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn test_consolidated_mutex_fields_are_consistent() {
let mgr = FallbackManager::for_model("primary-model");
mgr.add_fallback_model("fallback-model");
assert_eq!(mgr.active_model(), Some("primary-model".to_string()));
assert!(mgr.fallback_switched_at().is_none());
mgr.transition_to_fallback();
assert_eq!(mgr.active_model(), Some("fallback-model".to_string()));
assert!(
mgr.fallback_switched_at().is_some(),
"switch time should be set after transition"
);
}
#[test]
fn test_consolidated_mutex_clears_fields_together() {
let mgr = FallbackManager::for_model("primary-model");
mgr.add_fallback_model("fallback-model");
mgr.transition_to_fallback();
assert_eq!(mgr.active_model(), Some("fallback-model".to_string()));
assert!(mgr.fallback_switched_at().is_some());
mgr.transition_to_primary();
assert!(
mgr.fallback_switched_at().is_none(),
"switch time should be cleared after transition to primary"
);
}
#[test]
fn test_consolidated_mutex_reset_clears_all() {
let mgr = FallbackManager::for_model("primary-model");
mgr.add_fallback_model("fallback-model");
mgr.transition_to_fallback();
mgr.record_failure();
assert!(mgr.consecutive_failures() > 0);
assert!(mgr.fallback_switched_at().is_some());
mgr.reset();
assert_eq!(mgr.consecutive_failures(), 0);
assert!(mgr.fallback_switched_at().is_none());
}
#[test]
fn with_max_fail_count_no_padding_when_not_failed() {
let entry = FallbackEntry::new("model-a").with_max_fail_count(5);
assert!(!entry.failed());
assert_eq!(entry.attempt_count(), 0);
assert_eq!(entry.max_fail_count, 5);
}
#[test]
fn with_max_fail_count_pads_already_failed_entry() {
let mut entry = FallbackEntry::new("model-b");
entry.record_attempt("timeout");
entry.record_attempt("timeout");
assert!(entry.failed());
assert_eq!(entry.attempt_count(), 2);
let entry = entry.with_max_fail_count(5);
assert_eq!(entry.max_fail_count, 5);
assert_eq!(entry.attempt_count(), 5);
assert!(entry.failed());
}
#[test]
fn with_max_fail_count_pads_exactly_to_new_threshold() {
let mut entry = FallbackEntry::new("model-c");
entry.record_attempt("err");
entry.record_attempt("err");
assert!(entry.failed());
let entry = entry.with_max_fail_count(3);
assert_eq!(entry.attempt_count(), 3);
assert!(entry.failed());
}
#[test]
fn with_max_fail_count_no_padding_when_lowering() {
let mut entry = FallbackEntry::new("model-d");
entry.record_attempt("err");
entry.record_attempt("err");
assert!(entry.failed());
let entry = entry.with_max_fail_count(1);
assert_eq!(entry.max_fail_count, 1);
assert_eq!(entry.attempt_count(), 2);
assert!(entry.failed());
}
#[test]
fn with_max_fail_count_clamps_to_minimum_one() {
let entry = FallbackEntry::new("model-e").with_max_fail_count(0);
assert_eq!(entry.max_fail_count, 1);
}
}