weft_core/package/
circuit_breaker.rs1use std::collections::HashMap;
16use std::collections::VecDeque;
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20#[derive(Debug, Clone, Copy)]
23pub struct BreakerConfig {
24 pub window: usize,
26 pub failure_threshold: usize,
28 pub cooldown: Duration,
30}
31
32impl Default for BreakerConfig {
33 fn default() -> Self {
34 Self {
35 window: 10,
36 failure_threshold: 5,
37 cooldown: Duration::from_secs(30),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum State {
44 Closed,
45 Open,
46 HalfOpen,
47}
48
49#[derive(Debug)]
50struct Entry {
51 state: State,
52 window: VecDeque<bool>,
54 opened_at: Option<Instant>,
56}
57
58impl Entry {
59 fn new() -> Self {
60 Self {
61 state: State::Closed,
62 window: VecDeque::new(),
63 opened_at: None,
64 }
65 }
66
67 fn failures(&self) -> usize {
68 self.window.iter().filter(|&&f| f).count()
69 }
70}
71
72#[derive(Clone)]
74pub struct CircuitBreaker {
75 config: BreakerConfig,
76 entries: Arc<Mutex<HashMap<String, Entry>>>,
77}
78
79impl CircuitBreaker {
80 pub fn new(config: BreakerConfig) -> Self {
81 Self {
82 config,
83 entries: Arc::new(Mutex::new(HashMap::new())),
84 }
85 }
86
87 pub fn check_at(&self, package: &str, now: Instant) -> Result<(), String> {
93 let mut entries = self.entries.lock().unwrap();
94 let entry = entries.entry(package.to_string()).or_insert_with(Entry::new);
95
96 if entry.state == State::Open {
97 let cooled = entry
98 .opened_at
99 .map(|t| now.duration_since(t) >= self.config.cooldown)
100 .unwrap_or(true);
101 if cooled {
102 entry.state = State::HalfOpen;
104 return Ok(());
105 }
106 return Err(format!(
107 "package '{package}' circuit is open (too many recent failures); \
108 rejecting call until cooldown elapses"
109 ));
110 }
111
112 Ok(())
113 }
114
115 pub fn record_at(&self, package: &str, success: bool, now: Instant) {
117 let mut entries = self.entries.lock().unwrap();
118 let entry = entries.entry(package.to_string()).or_insert_with(Entry::new);
119
120 match entry.state {
121 State::HalfOpen => {
122 if success {
123 entry.state = State::Closed;
125 entry.window.clear();
126 entry.opened_at = None;
127 } else {
128 entry.state = State::Open;
130 entry.opened_at = Some(now);
131 }
132 }
133 State::Closed => {
134 entry.window.push_back(!success);
135 while entry.window.len() > self.config.window {
136 entry.window.pop_front();
137 }
138 if entry.failures() >= self.config.failure_threshold {
139 entry.state = State::Open;
140 entry.opened_at = Some(now);
141 }
142 }
143 State::Open => {
144 }
146 }
147 }
148
149 pub fn is_open(&self, package: &str) -> bool {
151 self.entries
152 .lock()
153 .unwrap()
154 .get(package)
155 .map(|e| e.state == State::Open)
156 .unwrap_or(false)
157 }
158
159 pub fn reset(&self, package: &str) {
161 if let Some(entry) = self.entries.lock().unwrap().get_mut(package) {
162 entry.state = State::Closed;
163 entry.window.clear();
164 entry.opened_at = None;
165 }
166 }
167
168 pub fn check(&self, package: &str) -> Result<(), String> {
170 self.check_at(package, Instant::now())
171 }
172 pub fn record(&self, package: &str, success: bool) {
173 self.record_at(package, success, Instant::now())
174 }
175}
176
177impl Default for CircuitBreaker {
178 fn default() -> Self {
179 Self::new(BreakerConfig::default())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn cfg() -> BreakerConfig {
188 BreakerConfig {
189 window: 5,
190 failure_threshold: 3,
191 cooldown: Duration::from_secs(10),
192 }
193 }
194
195 #[test]
196 fn healthy_package_stays_closed() {
197 let cb = CircuitBreaker::new(cfg());
198 let t = Instant::now();
199 for _ in 0..20 {
200 assert!(cb.check_at("p", t).is_ok());
201 cb.record_at("p", true, t);
202 }
203 assert!(!cb.is_open("p"));
204 }
205
206 #[test]
207 fn trips_open_after_threshold_failures() {
208 let cb = CircuitBreaker::new(cfg());
209 let t = Instant::now();
210 for _ in 0..3 {
211 assert!(cb.check_at("bad", t).is_ok());
212 cb.record_at("bad", false, t);
213 }
214 assert!(cb.is_open("bad"));
215 assert!(cb.check_at("bad", t).is_err());
217 }
218
219 #[test]
220 fn occasional_failures_below_threshold_stay_closed() {
221 let cb = CircuitBreaker::new(cfg());
222 let t = Instant::now();
223 for i in 0..15 {
226 cb.check_at("p", t).ok();
227 let success = i % 4 != 0; cb.record_at("p", success, t);
229 }
230 assert!(!cb.is_open("p"));
231 }
232
233 #[test]
234 fn halfopen_probe_success_closes() {
235 let cb = CircuitBreaker::new(cfg());
236 let t0 = Instant::now();
237 for _ in 0..3 {
238 cb.check_at("p", t0).ok();
239 cb.record_at("p", false, t0);
240 }
241 assert!(cb.check_at("p", t0).is_err()); let t1 = t0 + Duration::from_secs(11); assert!(cb.check_at("p", t1).is_ok()); cb.record_at("p", true, t1); assert!(!cb.is_open("p"));
246 assert!(cb.check_at("p", t1).is_ok());
247 }
248
249 #[test]
250 fn halfopen_probe_failure_reopens() {
251 let cb = CircuitBreaker::new(cfg());
252 let t0 = Instant::now();
253 for _ in 0..3 {
254 cb.check_at("p", t0).ok();
255 cb.record_at("p", false, t0);
256 }
257 let t1 = t0 + Duration::from_secs(11);
258 assert!(cb.check_at("p", t1).is_ok()); cb.record_at("p", false, t1); assert!(cb.is_open("p"));
261 assert!(cb.check_at("p", t1).is_err()); }
263
264 #[test]
265 fn reset_clears_open_state() {
266 let cb = CircuitBreaker::new(cfg());
267 let t = Instant::now();
268 for _ in 0..3 {
269 cb.check_at("p", t).ok();
270 cb.record_at("p", false, t);
271 }
272 assert!(cb.is_open("p"));
273 cb.reset("p");
274 assert!(!cb.is_open("p"));
275 assert!(cb.check_at("p", t).is_ok());
276 }
277}