Skip to main content

microclaw_engine/llm/
resilience.rs

1use super::*;
2
3/// Number of consecutive primary-model failures before the breaker opens.
4pub(crate) const BREAKER_FAILURE_THRESHOLD: u32 = 4;
5
6/// How long the breaker stays open (routing straight to the fallback) before it
7/// lets a primary probe through again.
8pub(crate) const BREAKER_COOLDOWN_SECS: u64 = 30;
9
10/// A simple consecutive-failure circuit breaker for the primary model. When the
11/// primary fails `threshold` times in a row the breaker opens for `cooldown`,
12/// during which calls skip the primary and go straight to the fallback; after
13/// the cooldown a single primary probe is allowed through (closing the breaker
14/// on success, reopening on failure).
15/// Process-wide provider failover stats, aggregated across every
16/// `ResilientProvider` instance (main + aux slots). Powers `/status`,
17/// the governance snapshot, and the "provider down" alert class.
18pub(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    /// Calls that were served by the fallback model since process start.
36    pub total_fallbacks: u64,
37    /// Provider failures since the last success (approximate: shared across
38    /// provider instances, reset by a success on any of them).
39    pub consecutive_failures: u64,
40    /// True while any circuit breaker's cooldown window is still open. The
41    /// window expires on its own and is NOT cleared by a success on a
42    /// different provider instance (an aux-slot success must not mask a
43    /// down primary), so this can briefly over-report a breaker that
44    /// recovered early — a conservative, fail-safe bias.
45    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        // Reset only this instance's contribution to the shared consecutive
93        // counter. The global breaker-open window is deliberately NOT
94        // cleared here: a success on this (possibly aux) instance must not
95        // mask a different instance's still-open primary breaker; the window
96        // expires on its own timeline.
97        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            // Extend the shared window to the furthest-out cooldown rather
109            // than overwriting, so concurrent breakers can't shorten it.
110            BREAKER_OPEN_UNTIL_EPOCH_MS.fetch_max(
111                epoch_ms() + self.cooldown.as_millis() as u64,
112                Ordering::Relaxed,
113            );
114        }
115    }
116}
117
118/// Decorator that adds a circuit breaker + model fallback around any
119/// `LlmProvider`. Non-streaming calls fall back to the configured model after a
120/// primary failure (or immediately while the breaker is open); streaming calls
121/// route to the fallback while the breaker is open but do not retry mid-stream
122/// (which would risk duplicating already-emitted output).
123pub 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        // Breaker open and the caller didn't pin a model → fail fast to fallback.
150        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                // Try the fallback once, unless the caller already asked for it.
173                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                // No mid-stream fallback: deltas may already have been emitted,
239                // so re-streaming would duplicate output. The next call will
240                // route to the fallback once the breaker opens.
241                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// ---------------------------------------------------------------------------
294// Anthropic provider
295// ---------------------------------------------------------------------------
296
297#[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        // A success fully resets the breaker.
321        cb.record_success();
322        assert!(!cb.is_open(t0));
323    }
324}