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    /// The reason of the last recorded failure for `provider`, open or not.
133    ///
134    /// The stall watchdog asks this to tell "out of credits" apart from the
135    /// other ways a provider leaves service: the former pauses the run for a
136    /// resume instead of failing it (issue #413).
137    pub fn last_reason(&self, provider: &str) -> Option<UnavailableReason> {
138        self.0.get(provider).map(|c| c.reason)
139    }
140
141    /// Forget every recorded failure, so the next dispatch is a real probe.
142    ///
143    /// Called on an explicit resume: the operator is saying conditions have
144    /// changed (most often a top-up after credits ran out), and holding the
145    /// retry until a cooldown lapses would make the resume look ignored.
146    pub fn reset(&mut self) {
147        self.0.clear();
148    }
149
150    /// Whether `provider` should be skipped right now.
151    ///
152    /// False once the cooldown has elapsed, which is what makes the next
153    /// request a probe without needing a distinct half-open state.
154    pub fn is_open(&self, provider: &str, now: i64, policy: &CircuitPolicy) -> bool {
155        self.0
156            .get(provider)
157            .and_then(|c| c.opened_at)
158            .is_some_and(|at| now.saturating_sub(at) < policy.cooldown_secs as i64)
159    }
160
161    /// Every currently-open circuit, provider-sorted so the rendering is
162    /// stable across ticks (a `HashMap` iteration order is not).
163    pub fn open_circuits(&self, now: i64, policy: &CircuitPolicy) -> Vec<ProviderCircuitState> {
164        let mut open: Vec<ProviderCircuitState> = self
165            .0
166            .iter()
167            .filter_map(|(provider, c)| {
168                let at = c.opened_at?;
169                let elapsed = now.saturating_sub(at);
170                let remaining = (policy.cooldown_secs as i64).saturating_sub(elapsed);
171                (remaining > 0).then(|| ProviderCircuitState {
172                    provider: provider.clone(),
173                    reason: c.reason,
174                    consecutive_failures: c.consecutive_failures,
175                    retry_in_secs: remaining as u64,
176                })
177            })
178            .collect();
179        open.sort_by(|a, b| a.provider.cmp(&b.provider));
180        open
181    }
182}
183
184/// Move any ready agent off a provider whose circuit is open, before dispatch
185/// gets to it.
186///
187/// This runs *serially*, unlike [`super::inference::dispatch_inference`], which
188/// fans out over `par_iter` and so cannot take the `&mut StageInference` a swap
189/// needs. Keeping the rotation here also means dispatch stays a pure decision:
190/// by the time it looks at an agent, the agent is already pointed at the best
191/// provider still standing.
192///
193/// An agent with nowhere left to go is left alone, and dispatch parks it on
194/// [`super::StallReason::ProviderCircuitOpen`].
195pub fn rotate_open_circuits(
196    mut agents: Query<(Entity, &AgentState, &mut StageInference), With<super::ReadyToInfer>>,
197    circuits: Option<Res<ProviderCircuits>>,
198    policy: Option<Res<CircuitPolicy>>,
199) {
200    crate::tick_scope::clear();
201    let Some(circuits) = circuits else {
202        return; // no breaker installed
203    };
204    let policy = policy.map(|p| *p).unwrap_or_default();
205    let now = chrono::Utc::now().timestamp();
206    for (entity, state, mut si) in agents.iter_mut() {
207        crate::tick_scope::enter(entity);
208        if state.status != crate::components::AgentStatus::Active {
209            continue;
210        }
211        if !circuits.is_open(&si.provider_name, now, &policy) {
212            continue;
213        }
214        // First candidate whose own circuit is closed. Everything skipped on
215        // the way is dropped: it is no better than what we are leaving.
216        let Some(next) = si
217            .fallbacks
218            .iter()
219            .position(|e| !circuits.is_open(&e.provider, now, &policy))
220        else {
221            continue; // nowhere to go; dispatch will park it
222        };
223        let entry = si.fallbacks.remove(next);
224        si.fallbacks.drain(..next);
225        tracing::warn!(
226            from_provider = %si.provider_name,
227            to_provider = %entry.provider,
228            to_model = %entry.model,
229            "provider circuit is open; moving this run to the next candidate"
230        );
231        si.provider_name = entry.provider;
232        si.model = entry.model;
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn policy() -> CircuitPolicy {
241        CircuitPolicy {
242            failures_before_open: 3,
243            cooldown_secs: 300,
244        }
245    }
246
247    fn fail(circuits: &mut ProviderCircuits, now: i64) -> bool {
248        circuits.record_failure(
249            "openrouter",
250            UnavailableReason::CreditsExhausted,
251            now,
252            &policy(),
253        )
254    }
255
256    #[test]
257    fn the_circuit_opens_only_at_the_threshold() {
258        let mut circuits = ProviderCircuits::default();
259        assert!(!fail(&mut circuits, 0));
260        assert!(!circuits.is_open("openrouter", 0, &policy()));
261        assert!(!fail(&mut circuits, 1));
262        assert!(!circuits.is_open("openrouter", 1, &policy()));
263        // Third strike: opens, and says so exactly once.
264        assert!(fail(&mut circuits, 2), "the transition is reported");
265        assert!(circuits.is_open("openrouter", 2, &policy()));
266        assert!(
267            !fail(&mut circuits, 3),
268            "already open, not a new transition"
269        );
270    }
271
272    #[test]
273    fn an_untouched_provider_is_never_open() {
274        let circuits = ProviderCircuits::default();
275        assert!(!circuits.is_open("anthropic", 0, &policy()));
276        assert!(circuits.open_circuits(0, &policy()).is_empty());
277    }
278
279    #[test]
280    fn a_success_closes_the_circuit() {
281        let mut circuits = ProviderCircuits::default();
282        for t in 0..3 {
283            fail(&mut circuits, t);
284        }
285        assert!(circuits.is_open("openrouter", 2, &policy()));
286        circuits.record_success("openrouter");
287        assert!(!circuits.is_open("openrouter", 2, &policy()));
288        // And the count restarts, so one later failure does not re-open it.
289        assert!(!fail(&mut circuits, 10));
290        assert!(!circuits.is_open("openrouter", 10, &policy()));
291    }
292
293    #[test]
294    fn last_reason_reports_the_most_recent_failure_or_nothing() {
295        let mut circuits = ProviderCircuits::default();
296        assert_eq!(circuits.last_reason("p"), None);
297        circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
298        assert_eq!(
299            circuits.last_reason("p"),
300            Some(UnavailableReason::CreditsExhausted),
301            "one failure is enough for the reason, open or not"
302        );
303    }
304
305    #[test]
306    fn reset_forgets_every_circuit() {
307        // What an explicit resume relies on: after a reset the next dispatch
308        // is a real probe rather than a wait for the cooldown (issue #413).
309        let mut circuits = ProviderCircuits::default();
310        let mut now = 0;
311        while !fail(&mut circuits, now) {
312            now += 1;
313        }
314        assert!(circuits.is_open("openrouter", now, &policy()));
315        circuits.reset();
316        assert!(!circuits.is_open("openrouter", now, &policy()));
317        assert_eq!(circuits.last_reason("openrouter"), None);
318    }
319
320    #[test]
321    fn the_cooldown_lets_a_probe_through() {
322        let mut circuits = ProviderCircuits::default();
323        for t in 0..3 {
324            fail(&mut circuits, t);
325        }
326        assert!(circuits.is_open("openrouter", 2 + 299, &policy()));
327        // Cooldown elapsed: the next dispatch is the probe.
328        assert!(!circuits.is_open("openrouter", 2 + 300, &policy()));
329    }
330
331    #[test]
332    fn a_failed_probe_restarts_the_cooldown() {
333        let mut circuits = ProviderCircuits::default();
334        for t in 0..3 {
335            fail(&mut circuits, t);
336        }
337        // Probe at the end of the cooldown, and it fails again.
338        assert!(
339            !fail(&mut circuits, 302),
340            "already open: not a new transition"
341        );
342        // The clock restarted from the probe rather than the original opening.
343        assert!(circuits.is_open("openrouter", 400, &policy()));
344        assert!(!circuits.is_open("openrouter", 602, &policy()));
345    }
346
347    #[test]
348    fn a_zero_threshold_disables_the_breaker() {
349        let disabled = CircuitPolicy {
350            failures_before_open: 0,
351            cooldown_secs: 300,
352        };
353        let mut circuits = ProviderCircuits::default();
354        for t in 0..10 {
355            assert!(!circuits.record_failure(
356                "openrouter",
357                UnavailableReason::CreditsExhausted,
358                t,
359                &disabled
360            ));
361        }
362        assert!(!circuits.is_open("openrouter", 10, &disabled));
363        assert!(circuits.open_circuits(10, &disabled).is_empty());
364    }
365
366    #[test]
367    fn open_circuits_reports_what_the_operator_needs() {
368        let mut circuits = ProviderCircuits::default();
369        for t in 0..3 {
370            fail(&mut circuits, t);
371        }
372        let open = circuits.open_circuits(102, &policy());
373        assert_eq!(open.len(), 1);
374        assert_eq!(open[0].provider, "openrouter");
375        assert_eq!(open[0].reason, UnavailableReason::CreditsExhausted);
376        assert_eq!(open[0].consecutive_failures, 3);
377        // Opened at t=2, cooldown 300, now 102 ⇒ 200 left.
378        assert_eq!(open[0].retry_in_secs, 200);
379    }
380
381    #[test]
382    fn open_circuits_is_sorted_and_drops_expired_ones() {
383        let mut circuits = ProviderCircuits::default();
384        for name in ["openrouter", "anthropic"] {
385            for t in 0..3 {
386                circuits.record_failure(name, UnavailableReason::AuthFailed, t, &policy());
387            }
388        }
389        let open = circuits.open_circuits(10, &policy());
390        assert_eq!(
391            open.iter().map(|c| c.provider.as_str()).collect::<Vec<_>>(),
392            vec!["anthropic", "openrouter"],
393            "a HashMap's order is not stable; the report must be"
394        );
395        // Past the cooldown they are no longer open, so nothing is reported.
396        assert!(circuits.open_circuits(1_000, &policy()).is_empty());
397    }
398
399    #[test]
400    fn the_latest_reason_wins() {
401        let mut circuits = ProviderCircuits::default();
402        circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
403        circuits.record_failure("p", UnavailableReason::AuthFailed, 1, &policy());
404        circuits.record_failure("p", UnavailableReason::AuthFailed, 2, &policy());
405        let open = circuits.open_circuits(2, &policy());
406        assert_eq!(open[0].reason, UnavailableReason::AuthFailed);
407    }
408
409    #[test]
410    fn the_default_policy_is_three_strikes_and_five_minutes() {
411        let p = CircuitPolicy::default();
412        assert_eq!(p.failures_before_open, DEFAULT_FAILURES_BEFORE_OPEN);
413        assert_eq!(p.cooldown_secs, DEFAULT_CIRCUIT_COOLDOWN_SECS);
414    }
415
416    // ── the rotation system ────────────────────────────────────────────────
417
418    fn agent_state() -> AgentState {
419        AgentState {
420            agent_id: "a".to_string(),
421            current_stage: "s".to_string(),
422            iteration: 0,
423            status: crate::components::AgentStatus::Active,
424            spawned_children_ids: vec![],
425            pending_wait: None,
426            accepts_messages: true,
427        }
428    }
429
430    fn stage_on(provider: &str, fallbacks: &[&str]) -> StageInference {
431        StageInference {
432            provider_name: provider.to_string(),
433            model: format!("{provider}-model"),
434            tools: Vec::new(),
435            tool_filter: None,
436            fallbacks: fallbacks
437                .iter()
438                .map(|p| {
439                    leviath_core::blueprint::ModelEntry::new((*p).to_string(), format!("{p}-model"))
440                })
441                .collect(),
442            output: None,
443        }
444    }
445
446    /// A world with `open` providers already tripped.
447    fn world_with_open(open: &[&str]) -> World {
448        let mut world = World::new();
449        let mut circuits = ProviderCircuits::default();
450        let now = chrono::Utc::now().timestamp();
451        for name in open {
452            for _ in 0..policy().failures_before_open {
453                circuits.record_failure(name, UnavailableReason::CreditsExhausted, now, &policy());
454            }
455        }
456        world.insert_resource(circuits);
457        world.insert_resource(policy());
458        world
459    }
460
461    fn run_rotate(world: &mut World) {
462        let mut schedule = Schedule::default();
463        schedule.add_systems(rotate_open_circuits);
464        schedule.run(world);
465    }
466
467    #[test]
468    fn rotation_moves_a_ready_agent_off_a_tripped_provider() {
469        let mut world = world_with_open(&["openrouter"]);
470        let e = world
471            .spawn((
472                agent_state(),
473                super::ReadyToInfer,
474                stage_on("openrouter", &["anthropic"]),
475            ))
476            .id();
477
478        run_rotate(&mut world);
479
480        let si = world.get::<StageInference>(e).unwrap();
481        assert_eq!(si.provider_name, "anthropic");
482        assert_eq!(si.model, "anthropic-model");
483        assert!(si.fallbacks.is_empty());
484    }
485
486    #[test]
487    fn rotation_skips_past_candidates_that_are_also_tripped() {
488        let mut world = world_with_open(&["openrouter", "openai"]);
489        let e = world
490            .spawn((
491                agent_state(),
492                super::ReadyToInfer,
493                stage_on("openrouter", &["openai", "anthropic"]),
494            ))
495            .id();
496
497        run_rotate(&mut world);
498
499        let si = world.get::<StageInference>(e).unwrap();
500        assert_eq!(si.provider_name, "anthropic");
501        // The tripped candidate is dropped rather than left to be tried next:
502        // it is no better than what we just left.
503        assert!(si.fallbacks.is_empty());
504    }
505
506    #[test]
507    fn rotation_leaves_an_agent_with_nowhere_to_go_alone() {
508        // Dispatch parks it on ProviderCircuitOpen; rotating to nothing would
509        // just lose the provider name the operator needs to see.
510        let mut world = world_with_open(&["openrouter"]);
511        let e = world
512            .spawn((
513                agent_state(),
514                super::ReadyToInfer,
515                stage_on("openrouter", &[]),
516            ))
517            .id();
518
519        run_rotate(&mut world);
520
521        assert_eq!(
522            world.get::<StageInference>(e).unwrap().provider_name,
523            "openrouter"
524        );
525    }
526
527    #[test]
528    fn rotation_leaves_a_healthy_provider_alone() {
529        let mut world = world_with_open(&["openrouter"]);
530        let e = world
531            .spawn((
532                agent_state(),
533                super::ReadyToInfer,
534                stage_on("anthropic", &["openai"]),
535            ))
536            .id();
537
538        run_rotate(&mut world);
539
540        let si = world.get::<StageInference>(e).unwrap();
541        assert_eq!(si.provider_name, "anthropic");
542        assert_eq!(si.fallbacks.len(), 1, "no candidate was spent");
543    }
544
545    #[test]
546    fn rotation_ignores_an_agent_that_is_not_active() {
547        // A paused run must not have its provider changed underneath it.
548        let mut world = world_with_open(&["openrouter"]);
549        let mut state = agent_state();
550        state.status = crate::components::AgentStatus::Paused;
551        let e = world
552            .spawn((
553                state,
554                super::ReadyToInfer,
555                stage_on("openrouter", &["anthropic"]),
556            ))
557            .id();
558
559        run_rotate(&mut world);
560
561        assert_eq!(
562            world.get::<StageInference>(e).unwrap().provider_name,
563            "openrouter"
564        );
565    }
566
567    #[test]
568    fn rotation_is_a_no_op_without_the_breaker_installed() {
569        // An embedder that never inserts the resource keeps the old behavior.
570        let mut world = World::new();
571        let e = world
572            .spawn((
573                agent_state(),
574                super::ReadyToInfer,
575                stage_on("openrouter", &["anthropic"]),
576            ))
577            .id();
578
579        run_rotate(&mut world);
580
581        assert_eq!(
582            world.get::<StageInference>(e).unwrap().provider_name,
583            "openrouter"
584        );
585    }
586
587    #[test]
588    fn rotation_falls_back_to_the_default_policy() {
589        // Circuits present, policy absent: the default must apply rather than
590        // the breaker silently doing nothing.
591        let mut world = World::new();
592        let mut circuits = ProviderCircuits::default();
593        let now = chrono::Utc::now().timestamp();
594        let default_policy = CircuitPolicy::default();
595        for _ in 0..default_policy.failures_before_open {
596            circuits.record_failure(
597                "openrouter",
598                UnavailableReason::CreditsExhausted,
599                now,
600                &default_policy,
601            );
602        }
603        world.insert_resource(circuits);
604        let e = world
605            .spawn((
606                agent_state(),
607                super::ReadyToInfer,
608                stage_on("openrouter", &["anthropic"]),
609            ))
610            .id();
611
612        run_rotate(&mut world);
613
614        assert_eq!(
615            world.get::<StageInference>(e).unwrap().provider_name,
616            "anthropic"
617        );
618    }
619}