Skip to main content

leviath_runtime/pipeline/
circuit.rs

1//! Per-provider circuit breakers: stop hammering a provider that has told us,
2//! repeatedly, that it cannot serve anyone.
3//!
4//! Failing over (see [`super::response::collect_inference`]) rescues one run.
5//! It does nothing for the *next* run, which starts on the same dead provider
6//! and burns its own failure discovering the same thing. Issue #201 is what
7//! that looks like at scale: ten consecutive workers, every one of them dying
8//! at iteration 0 against an OpenRouter account with no credits left.
9//!
10//! So failures are counted per provider. Past a threshold the circuit opens and
11//! dispatch stops choosing that provider at all, which turns a silent stream of
12//! dead runs into one visible state an operator can act on (`lev ps`, the
13//! `leviath.provider.circuit.open` gauge, and a `tracing::error!`).
14//!
15//! There is no half-open *state*, on purpose. `is_open` simply stops answering
16//! true once the cooldown has elapsed, so the next dispatch is the probe: it
17//! either succeeds and closes the circuit, or fails and re-opens it with a
18//! fresh timestamp. One less state machine to keep correct.
19
20use super::*;
21
22use std::collections::HashMap;
23
24use leviath_providers::UnavailableReason;
25use serde::{Deserialize, Serialize};
26
27/// When to open a provider's circuit, and how long to leave it open.
28///
29/// A world resource rather than constants because the daemon serves it from
30/// `[limits]`.
31#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CircuitPolicy {
33    /// Consecutive provider-fatal failures before the circuit opens. Zero
34    /// disables the breaker entirely, leaving only per-run failover.
35    pub failures_before_open: u32,
36    /// How long an open circuit is left alone before the next request is
37    /// allowed through as a probe.
38    pub cooldown_secs: u64,
39}
40
41/// Default consecutive failures before a provider's circuit opens.
42///
43/// Three rather than one: a single 402 can be a request that asked for more
44/// output tokens than the remaining balance covers, which a smaller request
45/// would survive. Three in a row is an account, not a request.
46pub const DEFAULT_FAILURES_BEFORE_OPEN: u32 = 3;
47
48/// Default time an open circuit waits before probing again.
49///
50/// Long enough that a drained account is not probed every few seconds, short
51/// enough that topping it up brings the factory back without a daemon restart.
52pub const DEFAULT_CIRCUIT_COOLDOWN_SECS: u64 = 300;
53
54impl Default for CircuitPolicy {
55    fn default() -> Self {
56        Self {
57            failures_before_open: DEFAULT_FAILURES_BEFORE_OPEN,
58            cooldown_secs: DEFAULT_CIRCUIT_COOLDOWN_SECS,
59        }
60    }
61}
62
63/// One provider's failure record. Absent from [`ProviderCircuits`] means
64/// healthy, so a success can simply drop the entry.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Circuit {
67    /// Provider-fatal failures since the last success.
68    pub consecutive_failures: u32,
69    /// When the circuit opened, if it is open. `None` while the count is still
70    /// below the threshold.
71    pub opened_at: Option<i64>,
72    /// What the provider last complained about, for the operator-facing text.
73    pub reason: UnavailableReason,
74}
75
76/// What an open circuit looks like to a client (`lev ps`, `--json`, telemetry).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ProviderCircuitState {
79    /// The provider whose circuit is open.
80    pub provider: String,
81    /// Why it was taken out of service.
82    pub reason: UnavailableReason,
83    /// How many consecutive failures it has accumulated.
84    pub consecutive_failures: u32,
85    /// Seconds until the next probe is allowed through.
86    pub retry_in_secs: u64,
87}
88
89/// Every provider's breaker state, as a world resource.
90///
91/// Written by the (serial) collect system and read by dispatch, so plain
92/// `Res`/`ResMut` access is enough - no interior mutability, no locks.
93#[derive(Resource, Debug, Clone, Default)]
94pub struct ProviderCircuits(HashMap<String, Circuit>);
95
96impl ProviderCircuits {
97    /// Count a provider-fatal failure against `provider`.
98    ///
99    /// Returns `true` on the transition into the open state, so the caller can
100    /// log and alert exactly once rather than on every subsequent failure.
101    pub fn record_failure(
102        &mut self,
103        provider: &str,
104        reason: UnavailableReason,
105        now: i64,
106        policy: &CircuitPolicy,
107    ) -> bool {
108        let entry = self.0.entry(provider.to_string()).or_insert(Circuit {
109            consecutive_failures: 0,
110            opened_at: None,
111            reason,
112        });
113        entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
114        entry.reason = reason;
115        if policy.failures_before_open == 0 {
116            return false; // breaker disabled; keep counting for the record
117        }
118        let was_open = entry.opened_at.is_some();
119        if entry.consecutive_failures >= policy.failures_before_open {
120            // Re-stamp on every failure at or past the threshold: a probe that
121            // fails must restart the cooldown, not inherit the old one.
122            entry.opened_at = Some(now);
123        }
124        !was_open && entry.opened_at.is_some()
125    }
126
127    /// Forget `provider`'s failures. Any success proves it is serving again.
128    pub fn record_success(&mut self, provider: &str) {
129        self.0.remove(provider);
130    }
131
132    /// Whether `provider` should be skipped right now.
133    ///
134    /// False once the cooldown has elapsed, which is what makes the next
135    /// request a probe without needing a distinct half-open state.
136    pub fn is_open(&self, provider: &str, now: i64, policy: &CircuitPolicy) -> bool {
137        self.0
138            .get(provider)
139            .and_then(|c| c.opened_at)
140            .is_some_and(|at| now.saturating_sub(at) < policy.cooldown_secs as i64)
141    }
142
143    /// Every currently-open circuit, provider-sorted so the rendering is
144    /// stable across ticks (a `HashMap` iteration order is not).
145    pub fn open_circuits(&self, now: i64, policy: &CircuitPolicy) -> Vec<ProviderCircuitState> {
146        let mut open: Vec<ProviderCircuitState> = self
147            .0
148            .iter()
149            .filter_map(|(provider, c)| {
150                let at = c.opened_at?;
151                let elapsed = now.saturating_sub(at);
152                let remaining = (policy.cooldown_secs as i64).saturating_sub(elapsed);
153                (remaining > 0).then(|| ProviderCircuitState {
154                    provider: provider.clone(),
155                    reason: c.reason,
156                    consecutive_failures: c.consecutive_failures,
157                    retry_in_secs: remaining as u64,
158                })
159            })
160            .collect();
161        open.sort_by(|a, b| a.provider.cmp(&b.provider));
162        open
163    }
164}
165
166/// Move any ready agent off a provider whose circuit is open, before dispatch
167/// gets to it.
168///
169/// This runs *serially*, unlike [`super::inference::dispatch_inference`], which
170/// fans out over `par_iter` and so cannot take the `&mut StageInference` a swap
171/// needs. Keeping the rotation here also means dispatch stays a pure decision:
172/// by the time it looks at an agent, the agent is already pointed at the best
173/// provider still standing.
174///
175/// An agent with nowhere left to go is left alone, and dispatch parks it on
176/// [`super::StallReason::ProviderCircuitOpen`].
177pub fn rotate_open_circuits(
178    mut agents: Query<(Entity, &AgentState, &mut StageInference), With<super::ReadyToInfer>>,
179    circuits: Option<Res<ProviderCircuits>>,
180    policy: Option<Res<CircuitPolicy>>,
181) {
182    crate::tick_scope::clear();
183    let Some(circuits) = circuits else {
184        return; // no breaker installed
185    };
186    let policy = policy.map(|p| *p).unwrap_or_default();
187    let now = chrono::Utc::now().timestamp();
188    for (entity, state, mut si) in agents.iter_mut() {
189        crate::tick_scope::enter(entity);
190        if state.status != crate::components::AgentStatus::Active {
191            continue;
192        }
193        if !circuits.is_open(&si.provider_name, now, &policy) {
194            continue;
195        }
196        // First candidate whose own circuit is closed. Everything skipped on
197        // the way is dropped: it is no better than what we are leaving.
198        let Some(next) = si
199            .fallbacks
200            .iter()
201            .position(|e| !circuits.is_open(&e.provider, now, &policy))
202        else {
203            continue; // nowhere to go; dispatch will park it
204        };
205        let entry = si.fallbacks.remove(next);
206        si.fallbacks.drain(..next);
207        tracing::warn!(
208            from_provider = %si.provider_name,
209            to_provider = %entry.provider,
210            to_model = %entry.model,
211            "provider circuit is open; moving this run to the next candidate"
212        );
213        si.provider_name = entry.provider;
214        si.model = entry.model;
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn policy() -> CircuitPolicy {
223        CircuitPolicy {
224            failures_before_open: 3,
225            cooldown_secs: 300,
226        }
227    }
228
229    fn fail(circuits: &mut ProviderCircuits, now: i64) -> bool {
230        circuits.record_failure(
231            "openrouter",
232            UnavailableReason::CreditsExhausted,
233            now,
234            &policy(),
235        )
236    }
237
238    #[test]
239    fn the_circuit_opens_only_at_the_threshold() {
240        let mut circuits = ProviderCircuits::default();
241        assert!(!fail(&mut circuits, 0));
242        assert!(!circuits.is_open("openrouter", 0, &policy()));
243        assert!(!fail(&mut circuits, 1));
244        assert!(!circuits.is_open("openrouter", 1, &policy()));
245        // Third strike: opens, and says so exactly once.
246        assert!(fail(&mut circuits, 2), "the transition is reported");
247        assert!(circuits.is_open("openrouter", 2, &policy()));
248        assert!(
249            !fail(&mut circuits, 3),
250            "already open, not a new transition"
251        );
252    }
253
254    #[test]
255    fn an_untouched_provider_is_never_open() {
256        let circuits = ProviderCircuits::default();
257        assert!(!circuits.is_open("anthropic", 0, &policy()));
258        assert!(circuits.open_circuits(0, &policy()).is_empty());
259    }
260
261    #[test]
262    fn a_success_closes_the_circuit() {
263        let mut circuits = ProviderCircuits::default();
264        for t in 0..3 {
265            fail(&mut circuits, t);
266        }
267        assert!(circuits.is_open("openrouter", 2, &policy()));
268        circuits.record_success("openrouter");
269        assert!(!circuits.is_open("openrouter", 2, &policy()));
270        // And the count restarts, so one later failure does not re-open it.
271        assert!(!fail(&mut circuits, 10));
272        assert!(!circuits.is_open("openrouter", 10, &policy()));
273    }
274
275    #[test]
276    fn the_cooldown_lets_a_probe_through() {
277        let mut circuits = ProviderCircuits::default();
278        for t in 0..3 {
279            fail(&mut circuits, t);
280        }
281        assert!(circuits.is_open("openrouter", 2 + 299, &policy()));
282        // Cooldown elapsed: the next dispatch is the probe.
283        assert!(!circuits.is_open("openrouter", 2 + 300, &policy()));
284    }
285
286    #[test]
287    fn a_failed_probe_restarts_the_cooldown() {
288        let mut circuits = ProviderCircuits::default();
289        for t in 0..3 {
290            fail(&mut circuits, t);
291        }
292        // Probe at the end of the cooldown, and it fails again.
293        assert!(
294            !fail(&mut circuits, 302),
295            "already open: not a new transition"
296        );
297        // The clock restarted from the probe rather than the original opening.
298        assert!(circuits.is_open("openrouter", 400, &policy()));
299        assert!(!circuits.is_open("openrouter", 602, &policy()));
300    }
301
302    #[test]
303    fn a_zero_threshold_disables_the_breaker() {
304        let disabled = CircuitPolicy {
305            failures_before_open: 0,
306            cooldown_secs: 300,
307        };
308        let mut circuits = ProviderCircuits::default();
309        for t in 0..10 {
310            assert!(!circuits.record_failure(
311                "openrouter",
312                UnavailableReason::CreditsExhausted,
313                t,
314                &disabled
315            ));
316        }
317        assert!(!circuits.is_open("openrouter", 10, &disabled));
318        assert!(circuits.open_circuits(10, &disabled).is_empty());
319    }
320
321    #[test]
322    fn open_circuits_reports_what_the_operator_needs() {
323        let mut circuits = ProviderCircuits::default();
324        for t in 0..3 {
325            fail(&mut circuits, t);
326        }
327        let open = circuits.open_circuits(102, &policy());
328        assert_eq!(open.len(), 1);
329        assert_eq!(open[0].provider, "openrouter");
330        assert_eq!(open[0].reason, UnavailableReason::CreditsExhausted);
331        assert_eq!(open[0].consecutive_failures, 3);
332        // Opened at t=2, cooldown 300, now 102 ⇒ 200 left.
333        assert_eq!(open[0].retry_in_secs, 200);
334    }
335
336    #[test]
337    fn open_circuits_is_sorted_and_drops_expired_ones() {
338        let mut circuits = ProviderCircuits::default();
339        for name in ["openrouter", "anthropic"] {
340            for t in 0..3 {
341                circuits.record_failure(name, UnavailableReason::AuthFailed, t, &policy());
342            }
343        }
344        let open = circuits.open_circuits(10, &policy());
345        assert_eq!(
346            open.iter().map(|c| c.provider.as_str()).collect::<Vec<_>>(),
347            vec!["anthropic", "openrouter"],
348            "a HashMap's order is not stable; the report must be"
349        );
350        // Past the cooldown they are no longer open, so nothing is reported.
351        assert!(circuits.open_circuits(1_000, &policy()).is_empty());
352    }
353
354    #[test]
355    fn the_latest_reason_wins() {
356        let mut circuits = ProviderCircuits::default();
357        circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
358        circuits.record_failure("p", UnavailableReason::AuthFailed, 1, &policy());
359        circuits.record_failure("p", UnavailableReason::AuthFailed, 2, &policy());
360        let open = circuits.open_circuits(2, &policy());
361        assert_eq!(open[0].reason, UnavailableReason::AuthFailed);
362    }
363
364    #[test]
365    fn the_default_policy_is_three_strikes_and_five_minutes() {
366        let p = CircuitPolicy::default();
367        assert_eq!(p.failures_before_open, DEFAULT_FAILURES_BEFORE_OPEN);
368        assert_eq!(p.cooldown_secs, DEFAULT_CIRCUIT_COOLDOWN_SECS);
369    }
370
371    // ── the rotation system ────────────────────────────────────────────────
372
373    fn agent_state() -> AgentState {
374        AgentState {
375            agent_id: "a".to_string(),
376            current_stage: "s".to_string(),
377            iteration: 0,
378            status: crate::components::AgentStatus::Active,
379            spawned_children_ids: vec![],
380            pending_wait: None,
381            accepts_messages: true,
382        }
383    }
384
385    fn stage_on(provider: &str, fallbacks: &[&str]) -> StageInference {
386        StageInference {
387            provider_name: provider.to_string(),
388            model: format!("{provider}-model"),
389            tools: Vec::new(),
390            tool_filter: None,
391            fallbacks: fallbacks
392                .iter()
393                .map(|p| {
394                    leviath_core::blueprint::ModelEntry::new((*p).to_string(), format!("{p}-model"))
395                })
396                .collect(),
397        }
398    }
399
400    /// A world with `open` providers already tripped.
401    fn world_with_open(open: &[&str]) -> World {
402        let mut world = World::new();
403        let mut circuits = ProviderCircuits::default();
404        let now = chrono::Utc::now().timestamp();
405        for name in open {
406            for _ in 0..policy().failures_before_open {
407                circuits.record_failure(name, UnavailableReason::CreditsExhausted, now, &policy());
408            }
409        }
410        world.insert_resource(circuits);
411        world.insert_resource(policy());
412        world
413    }
414
415    fn run_rotate(world: &mut World) {
416        let mut schedule = Schedule::default();
417        schedule.add_systems(rotate_open_circuits);
418        schedule.run(world);
419    }
420
421    #[test]
422    fn rotation_moves_a_ready_agent_off_a_tripped_provider() {
423        let mut world = world_with_open(&["openrouter"]);
424        let e = world
425            .spawn((
426                agent_state(),
427                super::ReadyToInfer,
428                stage_on("openrouter", &["anthropic"]),
429            ))
430            .id();
431
432        run_rotate(&mut world);
433
434        let si = world.get::<StageInference>(e).unwrap();
435        assert_eq!(si.provider_name, "anthropic");
436        assert_eq!(si.model, "anthropic-model");
437        assert!(si.fallbacks.is_empty());
438    }
439
440    #[test]
441    fn rotation_skips_past_candidates_that_are_also_tripped() {
442        let mut world = world_with_open(&["openrouter", "openai"]);
443        let e = world
444            .spawn((
445                agent_state(),
446                super::ReadyToInfer,
447                stage_on("openrouter", &["openai", "anthropic"]),
448            ))
449            .id();
450
451        run_rotate(&mut world);
452
453        let si = world.get::<StageInference>(e).unwrap();
454        assert_eq!(si.provider_name, "anthropic");
455        // The tripped candidate is dropped rather than left to be tried next:
456        // it is no better than what we just left.
457        assert!(si.fallbacks.is_empty());
458    }
459
460    #[test]
461    fn rotation_leaves_an_agent_with_nowhere_to_go_alone() {
462        // Dispatch parks it on ProviderCircuitOpen; rotating to nothing would
463        // just lose the provider name the operator needs to see.
464        let mut world = world_with_open(&["openrouter"]);
465        let e = world
466            .spawn((
467                agent_state(),
468                super::ReadyToInfer,
469                stage_on("openrouter", &[]),
470            ))
471            .id();
472
473        run_rotate(&mut world);
474
475        assert_eq!(
476            world.get::<StageInference>(e).unwrap().provider_name,
477            "openrouter"
478        );
479    }
480
481    #[test]
482    fn rotation_leaves_a_healthy_provider_alone() {
483        let mut world = world_with_open(&["openrouter"]);
484        let e = world
485            .spawn((
486                agent_state(),
487                super::ReadyToInfer,
488                stage_on("anthropic", &["openai"]),
489            ))
490            .id();
491
492        run_rotate(&mut world);
493
494        let si = world.get::<StageInference>(e).unwrap();
495        assert_eq!(si.provider_name, "anthropic");
496        assert_eq!(si.fallbacks.len(), 1, "no candidate was spent");
497    }
498
499    #[test]
500    fn rotation_ignores_an_agent_that_is_not_active() {
501        // A paused run must not have its provider changed underneath it.
502        let mut world = world_with_open(&["openrouter"]);
503        let mut state = agent_state();
504        state.status = crate::components::AgentStatus::Paused;
505        let e = world
506            .spawn((
507                state,
508                super::ReadyToInfer,
509                stage_on("openrouter", &["anthropic"]),
510            ))
511            .id();
512
513        run_rotate(&mut world);
514
515        assert_eq!(
516            world.get::<StageInference>(e).unwrap().provider_name,
517            "openrouter"
518        );
519    }
520
521    #[test]
522    fn rotation_is_a_no_op_without_the_breaker_installed() {
523        // An embedder that never inserts the resource keeps the old behavior.
524        let mut world = World::new();
525        let e = world
526            .spawn((
527                agent_state(),
528                super::ReadyToInfer,
529                stage_on("openrouter", &["anthropic"]),
530            ))
531            .id();
532
533        run_rotate(&mut world);
534
535        assert_eq!(
536            world.get::<StageInference>(e).unwrap().provider_name,
537            "openrouter"
538        );
539    }
540
541    #[test]
542    fn rotation_falls_back_to_the_default_policy() {
543        // Circuits present, policy absent: the default must apply rather than
544        // the breaker silently doing nothing.
545        let mut world = World::new();
546        let mut circuits = ProviderCircuits::default();
547        let now = chrono::Utc::now().timestamp();
548        let default_policy = CircuitPolicy::default();
549        for _ in 0..default_policy.failures_before_open {
550            circuits.record_failure(
551                "openrouter",
552                UnavailableReason::CreditsExhausted,
553                now,
554                &default_policy,
555            );
556        }
557        world.insert_resource(circuits);
558        let e = world
559            .spawn((
560                agent_state(),
561                super::ReadyToInfer,
562                stage_on("openrouter", &["anthropic"]),
563            ))
564            .id();
565
566        run_rotate(&mut world);
567
568        assert_eq!(
569            world.get::<StageInference>(e).unwrap().provider_name,
570            "anthropic"
571        );
572    }
573}