Skip to main content

agentd/runtime/
breaker.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The circuit breaker for remote-effect steps — `retry`'s cross-run sibling.
3//!
4//! `retry` remembers failures *within one step of one run*; when the remote is
5//! genuinely down, every new run still walks into it, burns its retry budget,
6//! and adds load to a dependency that needs the opposite. A breaker remembers
7//! across runs: after `failures` consecutive failures the circuit OPENS and
8//! further attempts fail immediately — no connection, no timeout wait — until
9//! `cooldown` has passed, when exactly ONE attempt is let through as a probe.
10//! The probe's outcome decides: success closes the circuit, failure re-opens
11//! it for another cooldown.
12//!
13//! Declared per step (`breaker: {failures: 5, cooldown: 60s}`) on the
14//! remote-effect kinds (`http`, `mcp.tool`, `a2a.send`, `a2a.delegate`).
15//! State is durable — keyed by workflow + the step's UNSCOPED id, so every
16//! fan-out iteration (`each[0].call`, `each[1].call`…) shares one breaker,
17//! because they share one dependency — and per instance: two replicas keep
18//! independent breakers, which is the honest scope for state that is really a
19//! local observation about a remote.
20//!
21//! This file is the pure state machine; the reactor owns WHEN it is consulted
22//! (single-writer, so there are no races to reason about). A fast-fail is
23//! reported through the normal step-failure path with [`OPEN_ERR`] as its
24//! error prefix — which composes: `retry` on the step turns into a bounded
25//! poll of the breaker, and `on_error: continue` + a `switch` on the error
26//! text is a fallback route.
27
28use serde_json::{Value, json};
29
30/// The error prefix a breaker fast-fail carries. The recorder skips errors
31/// with this prefix — a refusal to call is not evidence about the remote.
32pub const OPEN_ERR: &str = "breaker open";
33
34/// Parsed `breaker:` declaration.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Config {
37    /// Consecutive failures that open the circuit.
38    pub failures: u32,
39    pub cooldown_ms: u64,
40}
41
42impl Config {
43    /// Parse the (already-validated) `breaker:` value; `None` when absent or
44    /// malformed (validation refuses malformed at load — this is the backstop).
45    pub fn of(b: Option<&Value>) -> Option<Config> {
46        let b = b?;
47        let failures = b.get("failures")?.as_u64()? as u32;
48        let cooldown_ms = b
49            .get("cooldown")
50            .and_then(Value::as_str)
51            .and_then(|d| crate::config::parse_duration(d).ok())
52            .map(|d| d.as_millis() as u64)?;
53        (failures >= 1).then_some(Config {
54            failures,
55            cooldown_ms,
56        })
57    }
58}
59
60/// What the gate decides before an attempt is dispatched.
61#[derive(Debug, PartialEq)]
62pub enum Gate {
63    /// Closed (or this attempt is not guarded): dispatch the effect.
64    Proceed,
65    /// Cooldown elapsed and no live probe: dispatch — this attempt IS the
66    /// probe, and the state has been marked so.
67    Probe,
68    /// Open (or a probe is already in flight): fail fast without dialling.
69    /// Carries the milliseconds until the next probe becomes possible.
70    FastFail { retry_in_ms: u64 },
71}
72
73fn u(v: &Value, k: &str) -> u64 {
74    v.get(k).and_then(Value::as_u64).unwrap_or(0)
75}
76
77/// Consult (and update, for the probe claim) the breaker before an attempt.
78pub fn gate(state: &mut Value, cfg: Config, now_ms: u64) -> Gate {
79    if state.get("state").and_then(Value::as_str) != Some("open") {
80        return Gate::Proceed;
81    }
82    let opened = u(state, "opened_ms");
83    if now_ms < opened.saturating_add(cfg.cooldown_ms) {
84        return Gate::FastFail {
85            retry_in_ms: opened + cfg.cooldown_ms - now_ms,
86        };
87    }
88    // Half-open. One probe at a time; a probe record older than a cooldown is
89    // stale (its process died mid-dial, or its completion was lost) and a new
90    // probe may replace it rather than wedging the circuit open forever.
91    let probe = u(state, "probe_ms");
92    if probe != 0 && now_ms.saturating_sub(probe) < cfg.cooldown_ms {
93        return Gate::FastFail {
94            retry_in_ms: probe + cfg.cooldown_ms - now_ms,
95        };
96    }
97    state["probe_ms"] = json!(now_ms);
98    Gate::Probe
99}
100
101/// A state transition worth one log line (transitions only — a breaker that
102/// logged every guarded call would be its own kind of load).
103#[derive(Debug, PartialEq)]
104pub enum Transition {
105    None,
106    Opened { fails: u32 },
107    Reopened,
108    Closed,
109}
110
111/// Record an attempt's outcome. `ok` is the step's terminal disposition for
112/// this attempt; fast-fails (the [`OPEN_ERR`] prefix) must not reach here.
113pub fn record(state: &mut Value, cfg: Config, ok: bool, now_ms: u64) -> Transition {
114    let was_open = state.get("state").and_then(Value::as_str) == Some("open");
115    let probing = u(state, "probe_ms") != 0;
116    if ok {
117        let t = if was_open {
118            Transition::Closed
119        } else {
120            Transition::None
121        };
122        *state = json!({"state": "closed", "fails": 0});
123        return t;
124    }
125    if was_open && probing {
126        // The probe failed: the remote is still down. Re-open for another
127        // cooldown, measured from now.
128        *state = json!({"state": "open", "fails": u(state, "fails"), "opened_ms": now_ms});
129        return Transition::Reopened;
130    }
131    let fails = u(state, "fails") as u32 + 1;
132    if fails >= cfg.failures && !was_open {
133        *state = json!({"state": "open", "fails": fails, "opened_ms": now_ms});
134        return Transition::Opened { fails };
135    }
136    state["fails"] = json!(fails);
137    state["state"] = json!(if was_open { "open" } else { "closed" });
138    Transition::None
139}
140
141/// The durable key: workflow + the UNSCOPED step id (`each[0].call` → `call`),
142/// so fan-out iterations share the breaker of the dependency they share.
143pub fn key(workflow: &str, step_id: &str) -> String {
144    let base = step_id.rsplit("].").next().unwrap_or(step_id);
145    format!("{workflow}/{base}")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    const CFG: Config = Config {
153        failures: 3,
154        cooldown_ms: 1_000,
155    };
156
157    #[test]
158    fn opens_after_n_consecutive_failures_and_only_then() {
159        let mut s = json!({});
160        assert_eq!(record(&mut s, CFG, false, 10), Transition::None);
161        assert_eq!(record(&mut s, CFG, false, 20), Transition::None);
162        assert_eq!(gate(&mut s, CFG, 25), Gate::Proceed, "still closed at 2/3");
163        assert_eq!(
164            record(&mut s, CFG, false, 30),
165            Transition::Opened { fails: 3 }
166        );
167        assert_eq!(gate(&mut s, CFG, 40), Gate::FastFail { retry_in_ms: 990 });
168    }
169
170    #[test]
171    fn a_success_resets_the_consecutive_count() {
172        let mut s = json!({});
173        record(&mut s, CFG, false, 10);
174        record(&mut s, CFG, false, 20);
175        assert_eq!(record(&mut s, CFG, true, 30), Transition::None);
176        record(&mut s, CFG, false, 40);
177        record(&mut s, CFG, false, 50);
178        assert_eq!(
179            gate(&mut s, CFG, 60),
180            Gate::Proceed,
181            "consecutive means consecutive — 2+2 with a success between is not 4"
182        );
183    }
184
185    #[test]
186    fn one_probe_after_cooldown_success_closes() {
187        let mut s = json!({});
188        for t in [10, 20, 30] {
189            record(&mut s, CFG, false, t);
190        }
191        // During cooldown: everyone fails fast.
192        assert!(matches!(gate(&mut s, CFG, 500), Gate::FastFail { .. }));
193        // Cooldown over: exactly one probe; a second caller still fails fast.
194        assert_eq!(gate(&mut s, CFG, 1_100), Gate::Probe);
195        assert!(matches!(gate(&mut s, CFG, 1_101), Gate::FastFail { .. }));
196        assert_eq!(record(&mut s, CFG, true, 1_200), Transition::Closed);
197        assert_eq!(gate(&mut s, CFG, 1_300), Gate::Proceed);
198    }
199
200    #[test]
201    fn a_failed_probe_reopens_for_another_cooldown() {
202        let mut s = json!({});
203        for t in [10, 20, 30] {
204            record(&mut s, CFG, false, t);
205        }
206        assert_eq!(gate(&mut s, CFG, 1_100), Gate::Probe);
207        assert_eq!(record(&mut s, CFG, false, 1_150), Transition::Reopened);
208        assert!(matches!(gate(&mut s, CFG, 1_200), Gate::FastFail { .. }));
209        // …and the NEXT cooldown is measured from the probe's failure.
210        assert_eq!(gate(&mut s, CFG, 2_200), Gate::Probe);
211    }
212
213    #[test]
214    fn a_stale_probe_does_not_wedge_the_circuit() {
215        let mut s = json!({});
216        for t in [10, 20, 30] {
217            record(&mut s, CFG, false, t);
218        }
219        assert_eq!(gate(&mut s, CFG, 1_100), Gate::Probe);
220        // The probe's completion never arrives (process died). A full cooldown
221        // later, a new probe may claim the slot.
222        assert_eq!(gate(&mut s, CFG, 2_200), Gate::Probe);
223    }
224
225    #[test]
226    fn scoped_fanout_ids_share_one_breaker_key() {
227        assert_eq!(key("pay", "charge"), "pay/charge");
228        assert_eq!(key("pay", "each[0].charge"), "pay/charge");
229        assert_eq!(key("pay", "each[17].charge"), "pay/charge");
230        assert_ne!(key("pay", "charge"), key("bill", "charge"));
231    }
232
233    #[test]
234    fn config_parses_and_rejects_nonsense() {
235        let ok = json!({"failures": 5, "cooldown": "60s"});
236        assert_eq!(
237            Config::of(Some(&ok)),
238            Some(Config {
239                failures: 5,
240                cooldown_ms: 60_000
241            })
242        );
243        assert_eq!(Config::of(None), None);
244        for bad in [
245            json!({"failures": 0, "cooldown": "60s"}),
246            json!({"failures": 5}),
247            json!({"cooldown": "60s"}),
248            json!({"failures": 5, "cooldown": "soon"}),
249        ] {
250            assert_eq!(Config::of(Some(&bad)), None, "{bad}");
251        }
252    }
253}