Skip to main content

weft_core/package/
circuit_breaker.rs

1//! Per-package circuit breaker for WASM call fault isolation (A2).
2//!
3//! A repeatedly-trapping or panicking WASM package should not be invoked
4//! forever — each failed call costs a lock, an instantiation attempt, and (for
5//! crash loops) log spam. This breaker tracks recent call outcomes per package
6//! and, once failures exceed a threshold within a sliding window, trips to
7//! `Open`: subsequent calls are rejected immediately without touching the
8//! plugin. After a cooldown it moves to `HalfOpen` and lets a single probe
9//! through; success closes the breaker, failure re-opens it.
10//!
11//! "Failure" here means the host-side `Plugin::call` returned `Err` (a trap,
12//! panic, or instantiation error) — NOT a well-formed `{"error":...}` JSON
13//! payload, which is a normal business-level result the guest chose to return.
14
15use std::collections::HashMap;
16use std::collections::VecDeque;
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20/// Tunable thresholds. Defaults are deliberately lenient so healthy packages
21/// with occasional hiccups are never tripped.
22#[derive(Debug, Clone, Copy)]
23pub struct BreakerConfig {
24    /// Sliding window size (most recent N outcomes considered).
25    pub window: usize,
26    /// Number of failures within the window required to trip Open.
27    pub failure_threshold: usize,
28    /// How long to stay Open before allowing a HalfOpen probe.
29    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    /// Recent outcomes: true = failure, false = success.
53    window: VecDeque<bool>,
54    /// When the breaker last tripped Open (for cooldown).
55    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/// Thread-safe, clonable per-package circuit breaker.
73#[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    /// Returns `Err(reason)` if the package is currently tripped Open and still
88    /// within its cooldown — the caller should reject the call without invoking
89    /// the plugin. Otherwise `Ok(())` (Closed or a HalfOpen probe is allowed).
90    ///
91    /// `now` is injected so the logic is deterministic in tests.
92    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                // Allow a single probe through.
103                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    /// Records the outcome of a call and updates the breaker state.
116    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                    // Probe succeeded → close and reset.
124                    entry.state = State::Closed;
125                    entry.window.clear();
126                    entry.opened_at = None;
127                } else {
128                    // Probe failed → re-open, restart cooldown.
129                    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                // Outcome arriving while Open (race with a probe); ignore.
145            }
146        }
147    }
148
149    /// True if the package is currently tripped Open (diagnostic / status use).
150    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    /// Manually reset a package's breaker (e.g. after a reload/upgrade).
160    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    // Convenience wrappers using the real clock for production call sites.
169    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        // Subsequent call rejected within cooldown.
216        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        // window=5, threshold=3: fail 1 in every 4 calls → at most 2 failures
224        // in any window of 5, never reaching the threshold.
225        for i in 0..15 {
226            cb.check_at("p", t).ok();
227            let success = i % 4 != 0; // fail on 0,4,8,12 → spaced 4 apart
228            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()); // open, within cooldown
242        let t1 = t0 + Duration::from_secs(11); // past cooldown
243        assert!(cb.check_at("p", t1).is_ok()); // half-open probe allowed
244        cb.record_at("p", true, t1); // probe succeeds
245        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()); // probe allowed
259        cb.record_at("p", false, t1); // probe fails
260        assert!(cb.is_open("p"));
261        assert!(cb.check_at("p", t1).is_err()); // open again
262    }
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}