1use serde_json::{Value, json};
29
30pub const OPEN_ERR: &str = "breaker open";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Config {
37 pub failures: u32,
39 pub cooldown_ms: u64,
40}
41
42impl Config {
43 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#[derive(Debug, PartialEq)]
62pub enum Gate {
63 Proceed,
65 Probe,
68 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
77pub 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 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#[derive(Debug, PartialEq)]
104pub enum Transition {
105 None,
106 Opened { fails: u32 },
107 Reopened,
108 Closed,
109}
110
111pub 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 *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
141pub 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 assert!(matches!(gate(&mut s, CFG, 500), Gate::FastFail { .. }));
193 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 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 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}