-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcircuit_breaker.rs
More file actions
296 lines (273 loc) · 10.9 KB
/
Copy pathcircuit_breaker.rs
File metadata and controls
296 lines (273 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
//! Circuit breaker for resilient service calls.
//!
//! Implements the closed/open/half-open state machine with the
//! single-probe rule: when the breaker is in `HalfOpen` only **one**
//! probe call may be in flight at a time; subsequent calls
//! short-circuit with [`CircuitBreakerError::ProbeInFlight`] until the
//! probe completes.
//!
//! The design was TLA+-verified before implementation (see
//! [`docs/slo/design/circuit-breaker-verified.md`](../../../docs/slo/design/circuit-breaker-verified.md)
//! and [`specs/CircuitBreaker.tla`](../../../specs/CircuitBreaker.tla)).
//! The Naive variant of the spec deliberately omits the
//! `probe_inflight` check; TLC must find the double-probe
//! counterexample before this implementation is considered sound.
//!
//! # Threading model
//!
//! The breaker is **single-process**. Internal state is guarded by a
//! `Mutex` for the lifecycle fields and an `AtomicBool` for the
//! `probe_inflight` flag (the load-bearing invariant of the half-open
//! design). Distributed circuit breakers are out of scope; that
//! requires a separate runbook.
//!
//! # Example
//!
//! ```
//! use secure_resilience::circuit_breaker::{CircuitBreaker, CircuitBreakerError, CircuitBreakerPolicy};
//! use std::time::Duration;
//!
//! let policy = CircuitBreakerPolicy::new()
//! .with_failure_threshold(3)
//! .with_open_duration(Duration::from_millis(100));
//! let breaker = CircuitBreaker::new(policy);
//!
//! // Wraps any FnOnce returning Result<T, E>.
//! let result: Result<u32, CircuitBreakerError<&'static str>> =
//! breaker.call(|| Ok::<_, &'static str>(42));
//! assert_eq!(result.unwrap(), 42);
//! ```
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::error::ResilienceError;
/// Lifecycle state of the circuit breaker.
///
/// State transitions are TLA+-verified in
/// [`specs/CircuitBreaker.tla`](../../../specs/CircuitBreaker.tla).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitBreakerState {
/// Normal operation — calls pass through; failures count toward the
/// threshold.
Closed,
/// The threshold was crossed — calls short-circuit with
/// [`CircuitBreakerError::CircuitOpen`] until `open_duration`
/// elapses.
Open,
/// The open-duration has elapsed and the breaker is willing to try
/// **one** probe call. The single-probe rule is enforced via
/// `probe_inflight` — concurrent calls during the probe receive
/// [`CircuitBreakerError::ProbeInFlight`].
HalfOpen,
}
/// Configuration for a [`CircuitBreaker`].
#[derive(Debug, Clone)]
pub struct CircuitBreakerPolicy {
failure_threshold: u32,
open_duration: Duration,
}
impl CircuitBreakerPolicy {
/// Default policy: failure threshold = 5, open duration = 30s.
#[must_use]
pub fn new() -> Self {
Self {
failure_threshold: 5,
open_duration: Duration::from_secs(30),
}
}
/// Number of consecutive failures that trip the breaker.
#[must_use]
pub fn with_failure_threshold(mut self, threshold: u32) -> Self {
self.failure_threshold = threshold;
self
}
/// Duration the breaker stays in `Open` before transitioning to
/// `HalfOpen` and accepting a single probe.
#[must_use]
pub fn with_open_duration(mut self, duration: Duration) -> Self {
self.open_duration = duration;
self
}
}
impl Default for CircuitBreakerPolicy {
fn default() -> Self {
Self::new()
}
}
/// Error produced by [`CircuitBreaker::call`].
///
/// `E` is the error type produced by the wrapped closure when it fails
/// for a downstream reason (network error, parse error, etc.).
#[derive(Debug)]
pub enum CircuitBreakerError<E> {
/// The breaker is `Open`; the call short-circuited without
/// invoking the closure.
CircuitOpen,
/// The breaker is `HalfOpen` and a probe is already in flight; the
/// call short-circuited per the single-probe rule.
ProbeInFlight,
/// The wrapped closure returned an error. The breaker counted
/// this toward the failure threshold.
DownstreamFailed(E),
/// An internal lock could not be acquired (poisoned mutex).
Internal(ResilienceError),
}
impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CircuitOpen => f.write_str("circuit breaker is open"),
Self::ProbeInFlight => {
f.write_str("circuit breaker is half-open and a probe is in flight")
}
Self::DownstreamFailed(e) => write!(f, "downstream call failed: {e}"),
Self::Internal(e) => write!(f, "internal error: {e}"),
}
}
}
impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for CircuitBreakerError<E> {}
#[derive(Debug)]
struct Internal {
state: CircuitBreakerState,
failure_count: u32,
opened_at: Option<Instant>,
}
/// Single-process circuit breaker.
///
/// Wrap downstream calls with [`Self::call`] to gain failure isolation:
/// after the configured threshold of consecutive failures, the breaker
/// short-circuits subsequent calls until the open-duration elapses.
/// Then a single probe is permitted; on probe success the breaker
/// closes, on probe failure it re-opens.
#[derive(Debug)]
pub struct CircuitBreaker {
policy: CircuitBreakerPolicy,
inner: Mutex<Internal>,
/// Load-bearing invariant for the half-open design. The TLA+
/// `NoDoubleProbe` property is the property this flag enforces.
probe_inflight: AtomicBool,
}
impl CircuitBreaker {
/// Create a new breaker with the given policy.
#[must_use]
pub fn new(policy: CircuitBreakerPolicy) -> Self {
Self {
policy,
inner: Mutex::new(Internal {
state: CircuitBreakerState::Closed,
failure_count: 0,
opened_at: None,
}),
probe_inflight: AtomicBool::new(false),
}
}
/// Returns the current state. (Used by tests; production callers
/// don't typically introspect the state directly.)
pub fn state(&self) -> Result<CircuitBreakerState, ResilienceError> {
let guard = self
.inner
.lock()
.map_err(|_| ResilienceError::Internal("circuit-breaker mutex poisoned".into()))?;
Ok(guard.state)
}
/// Wrap a downstream call with the circuit breaker.
///
/// - In `Closed` state, the closure runs and successes/failures
/// accumulate toward the threshold.
/// - In `Open` state, the closure does not run; the call returns
/// [`CircuitBreakerError::CircuitOpen`].
/// - In `HalfOpen` state, **one** probe call is permitted; further
/// concurrent calls return [`CircuitBreakerError::ProbeInFlight`].
/// On probe success the breaker transitions to `Closed`; on
/// probe failure it returns to `Open`.
///
/// # Errors
///
/// - [`CircuitBreakerError::CircuitOpen`] when `Open`.
/// - [`CircuitBreakerError::ProbeInFlight`] when `HalfOpen` and the
/// single probe is already running.
/// - [`CircuitBreakerError::DownstreamFailed`] when the closure
/// itself fails.
/// - [`CircuitBreakerError::Internal`] on internal lock poisoning.
pub fn call<T, E, F>(&self, f: F) -> Result<T, CircuitBreakerError<E>>
where
F: FnOnce() -> Result<T, E>,
{
// Phase 1: pre-call state check + probe reservation.
let allow_probe = {
let mut guard = self.inner.lock().map_err(|_| {
CircuitBreakerError::Internal(ResilienceError::Internal(
"circuit-breaker mutex poisoned".into(),
))
})?;
// First, age the open-state into half-open if the timer has elapsed.
if guard.state == CircuitBreakerState::Open {
if let Some(opened_at) = guard.opened_at {
if opened_at.elapsed() >= self.policy.open_duration {
guard.state = CircuitBreakerState::HalfOpen;
}
}
}
match guard.state {
CircuitBreakerState::Closed => false,
CircuitBreakerState::Open => return Err(CircuitBreakerError::CircuitOpen),
CircuitBreakerState::HalfOpen => true,
}
};
// Phase 2: single-probe reservation (only relevant in HalfOpen).
if allow_probe {
// Reserve the probe slot atomically. If another caller has
// already reserved it, return ProbeInFlight without
// touching the closure.
if self
.probe_inflight
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(CircuitBreakerError::ProbeInFlight);
}
}
// Phase 3: invoke the closure.
let outcome = f();
// Phase 4: post-call state transition.
let mut guard = self.inner.lock().map_err(|_| {
// Release the probe reservation even on poison so a future
// breaker (after recovery) is in a known state.
self.probe_inflight.store(false, Ordering::SeqCst);
CircuitBreakerError::Internal(ResilienceError::Internal(
"circuit-breaker mutex poisoned".into(),
))
})?;
match (&outcome, guard.state) {
// Probe success in HalfOpen → transition to Closed.
(Ok(_), CircuitBreakerState::HalfOpen) => {
guard.state = CircuitBreakerState::Closed;
guard.failure_count = 0;
guard.opened_at = None;
self.probe_inflight.store(false, Ordering::SeqCst);
}
// Probe failure in HalfOpen → re-open.
(Err(_), CircuitBreakerState::HalfOpen) => {
guard.state = CircuitBreakerState::Open;
guard.opened_at = Some(Instant::now());
self.probe_inflight.store(false, Ordering::SeqCst);
}
// Closed-path success → reset failure counter.
(Ok(_), CircuitBreakerState::Closed) => {
guard.failure_count = 0;
}
// Closed-path failure → bump counter; trip if threshold met.
(Err(_), CircuitBreakerState::Closed) => {
guard.failure_count += 1;
if guard.failure_count >= self.policy.failure_threshold {
guard.state = CircuitBreakerState::Open;
guard.opened_at = Some(Instant::now());
}
}
// Should not happen — Phase 1 short-circuits on Open.
(_, CircuitBreakerState::Open) => {}
}
// Drop guard before potentially returning the error variant.
drop(guard);
outcome.map_err(CircuitBreakerError::DownstreamFailed)
}
}