1use super::*;
2
3pub(crate) const BREAKER_FAILURE_THRESHOLD: u32 = 4;
5
6pub(crate) const BREAKER_COOLDOWN_SECS: u64 = 30;
9
10pub(crate) static FAILOVER_TOTAL: AtomicU64 = AtomicU64::new(0);
19
20pub(crate) static FAILURES_CONSECUTIVE: AtomicU64 = AtomicU64::new(0);
21
22pub(crate) static BREAKER_OPEN_UNTIL_EPOCH_MS: AtomicU64 = AtomicU64::new(0);
23
24pub(crate) static LAST_FAILURE_EPOCH_MS: AtomicU64 = AtomicU64::new(0);
25
26pub(crate) fn epoch_ms() -> u64 {
27 std::time::SystemTime::now()
28 .duration_since(std::time::UNIX_EPOCH)
29 .map(|d| d.as_millis() as u64)
30 .unwrap_or(0)
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct ProviderFailoverSnapshot {
35 pub total_fallbacks: u64,
37 pub consecutive_failures: u64,
40 pub breaker_open: bool,
46 pub last_failure_epoch_ms: Option<u64>,
47}
48
49pub fn provider_failover_snapshot() -> ProviderFailoverSnapshot {
50 let last = LAST_FAILURE_EPOCH_MS.load(Ordering::Relaxed);
51 ProviderFailoverSnapshot {
52 total_fallbacks: FAILOVER_TOTAL.load(Ordering::Relaxed),
53 consecutive_failures: FAILURES_CONSECUTIVE.load(Ordering::Relaxed),
54 breaker_open: BREAKER_OPEN_UNTIL_EPOCH_MS.load(Ordering::Relaxed) > epoch_ms(),
55 last_failure_epoch_ms: if last == 0 { None } else { Some(last) },
56 }
57}
58
59pub(crate) fn note_fallback_used() {
60 FAILOVER_TOTAL.fetch_add(1, Ordering::Relaxed);
61}
62
63pub(crate) struct CircuitBreaker {
64 failures: AtomicU32,
65 open_until: Mutex<Option<Instant>>,
66 threshold: u32,
67 cooldown: Duration,
68}
69
70impl CircuitBreaker {
71 pub(crate) fn new(threshold: u32, cooldown: Duration) -> Self {
72 Self {
73 failures: AtomicU32::new(0),
74 open_until: Mutex::new(None),
75 threshold,
76 cooldown,
77 }
78 }
79
80 pub(crate) fn is_open(&self, now: Instant) -> bool {
81 match *self.open_until.lock().unwrap_or_else(|p| p.into_inner()) {
82 Some(until) => now < until,
83 None => false,
84 }
85 }
86
87 pub(crate) fn record_success(&self) {
88 self.failures.store(0, Ordering::Relaxed);
89 if let Ok(mut guard) = self.open_until.lock() {
90 *guard = None;
91 }
92 FAILURES_CONSECUTIVE.store(0, Ordering::Relaxed);
98 }
99
100 pub(crate) fn record_failure(&self, now: Instant) {
101 let n = self.failures.fetch_add(1, Ordering::Relaxed) + 1;
102 FAILURES_CONSECUTIVE.fetch_add(1, Ordering::Relaxed);
103 LAST_FAILURE_EPOCH_MS.store(epoch_ms(), Ordering::Relaxed);
104 if n >= self.threshold {
105 if let Ok(mut guard) = self.open_until.lock() {
106 *guard = Some(now + self.cooldown);
107 }
108 BREAKER_OPEN_UNTIL_EPOCH_MS.fetch_max(
111 epoch_ms() + self.cooldown.as_millis() as u64,
112 Ordering::Relaxed,
113 );
114 }
115 }
116}
117
118pub struct ResilientProvider {
124 inner: Box<dyn LlmProvider>,
125 fallback_model: String,
126 breaker: CircuitBreaker,
127}
128
129impl ResilientProvider {
130 pub(crate) fn new(inner: Box<dyn LlmProvider>, fallback_model: String) -> Self {
131 Self {
132 inner,
133 fallback_model,
134 breaker: CircuitBreaker::new(
135 BREAKER_FAILURE_THRESHOLD,
136 Duration::from_secs(BREAKER_COOLDOWN_SECS),
137 ),
138 }
139 }
140
141 async fn call(
142 &self,
143 system: &str,
144 messages: Vec<Message>,
145 tools: Option<Vec<ToolDefinition>>,
146 model_override: Option<&str>,
147 ) -> Result<MessagesResponse, MicroClawError> {
148 let now = Instant::now();
149 if model_override.is_none() && self.breaker.is_open(now) {
151 warn!(
152 "LLM circuit breaker open; routing to fallback model {}",
153 self.fallback_model
154 );
155 note_fallback_used();
156 return self
157 .inner
158 .send_message_with_model(system, messages, tools, Some(&self.fallback_model))
159 .await;
160 }
161 match self
162 .inner
163 .send_message_with_model(system, messages.clone(), tools.clone(), model_override)
164 .await
165 {
166 Ok(r) => {
167 self.breaker.record_success();
168 Ok(r)
169 }
170 Err(e) => {
171 self.breaker.record_failure(now);
172 if model_override != Some(self.fallback_model.as_str()) {
174 warn!(
175 "primary LLM call failed ({e}); retrying with fallback model {}",
176 self.fallback_model
177 );
178 note_fallback_used();
179 match self
180 .inner
181 .send_message_with_model(
182 system,
183 messages,
184 tools,
185 Some(&self.fallback_model),
186 )
187 .await
188 {
189 Ok(r) => Ok(r),
190 Err(e2) => {
191 warn!("fallback model also failed: {e2}");
192 Err(e)
193 }
194 }
195 } else {
196 Err(e)
197 }
198 }
199 }
200 }
201
202 async fn call_stream(
203 &self,
204 system: &str,
205 messages: Vec<Message>,
206 tools: Option<Vec<ToolDefinition>>,
207 text_tx: Option<&UnboundedSender<String>>,
208 model_override: Option<&str>,
209 ) -> Result<MessagesResponse, MicroClawError> {
210 let now = Instant::now();
211 if model_override.is_none() && self.breaker.is_open(now) {
212 warn!(
213 "LLM circuit breaker open; streaming via fallback model {}",
214 self.fallback_model
215 );
216 note_fallback_used();
217 return self
218 .inner
219 .send_message_stream_with_model(
220 system,
221 messages,
222 tools,
223 text_tx,
224 Some(&self.fallback_model),
225 )
226 .await;
227 }
228 match self
229 .inner
230 .send_message_stream_with_model(system, messages, tools, text_tx, model_override)
231 .await
232 {
233 Ok(r) => {
234 self.breaker.record_success();
235 Ok(r)
236 }
237 Err(e) => {
238 self.breaker.record_failure(now);
242 Err(e)
243 }
244 }
245 }
246}
247
248#[async_trait]
249impl LlmProvider for ResilientProvider {
250 async fn send_message(
251 &self,
252 system: &str,
253 messages: Vec<Message>,
254 tools: Option<Vec<ToolDefinition>>,
255 ) -> Result<MessagesResponse, MicroClawError> {
256 self.call(system, messages, tools, None).await
257 }
258
259 async fn send_message_with_model(
260 &self,
261 system: &str,
262 messages: Vec<Message>,
263 tools: Option<Vec<ToolDefinition>>,
264 model_override: Option<&str>,
265 ) -> Result<MessagesResponse, MicroClawError> {
266 self.call(system, messages, tools, model_override).await
267 }
268
269 async fn send_message_stream(
270 &self,
271 system: &str,
272 messages: Vec<Message>,
273 tools: Option<Vec<ToolDefinition>>,
274 text_tx: Option<&UnboundedSender<String>>,
275 ) -> Result<MessagesResponse, MicroClawError> {
276 self.call_stream(system, messages, tools, text_tx, None)
277 .await
278 }
279
280 async fn send_message_stream_with_model(
281 &self,
282 system: &str,
283 messages: Vec<Message>,
284 tools: Option<Vec<ToolDefinition>>,
285 text_tx: Option<&UnboundedSender<String>>,
286 model_override: Option<&str>,
287 ) -> Result<MessagesResponse, MicroClawError> {
288 self.call_stream(system, messages, tools, text_tx, model_override)
289 .await
290 }
291}
292
293#[cfg(test)]
298mod tests {
299 use super::*;
300 #[allow(unused_imports)]
301 use crate::llm::test_prelude::*;
302
303 #[test]
304 pub(crate) fn test_circuit_breaker_opens_and_recovers() {
305 let cb = CircuitBreaker::new(2, Duration::from_secs(60));
306 let t0 = Instant::now();
307 assert!(!cb.is_open(t0));
308 cb.record_failure(t0);
309 assert!(!cb.is_open(t0), "one failure below threshold must not open");
310 cb.record_failure(t0);
311 assert!(cb.is_open(t0), "threshold failures must open the breaker");
312 assert!(
313 cb.is_open(t0 + Duration::from_secs(59)),
314 "open during cooldown"
315 );
316 assert!(
317 !cb.is_open(t0 + Duration::from_secs(61)),
318 "closed (half-open probe) after cooldown"
319 );
320 cb.record_success();
322 assert!(!cb.is_open(t0));
323 }
324}