breaker_machines 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ext/breaker_machines_native/Cargo.toml +8 -0
- data/ext/breaker_machines_native/core/Cargo.toml +18 -0
- data/ext/breaker_machines_native/core/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/core/src/builder.rs +232 -0
- data/ext/breaker_machines_native/core/src/bulkhead.rs +223 -0
- data/ext/breaker_machines_native/core/src/callbacks.rs +58 -0
- data/ext/breaker_machines_native/core/src/circuit.rs +1156 -0
- data/ext/breaker_machines_native/core/src/classifier.rs +177 -0
- data/ext/breaker_machines_native/core/src/errors.rs +47 -0
- data/ext/breaker_machines_native/core/src/lib.rs +62 -0
- data/ext/breaker_machines_native/core/src/storage.rs +377 -0
- data/ext/breaker_machines_native/extconf.rb +40 -0
- data/ext/breaker_machines_native/ffi/Cargo.toml +16 -0
- data/ext/breaker_machines_native/ffi/src/lib.rs +218 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/common.rs +355 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/dynamic.rs +276 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/macros.rs +49 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-2bb7281aac8faec8/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-54cb99ea6aeab8bc/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-9e64a270c6421e93/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-e627030114d3fc19/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/builder.rs +154 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/circuit.rs +607 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/builder.rs +173 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/circuit.rs +855 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/builder.rs +154 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/circuit.rs +607 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/builder.rs +232 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/bulkhead.rs +223 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/callbacks.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/circuit.rs +1156 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/classifier.rs +177 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/errors.rs +47 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/lib.rs +62 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/common.rs +355 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/dynamic.rs +276 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/macros.rs +49 -0
- data/ext/breaker_machines_native/target/release/build/rb-sys-064bf9961dd17810/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/lib/breaker_machines/circuit/async_state_management.rb +1 -1
- data/lib/breaker_machines/circuit/base.rb +2 -1
- data/lib/breaker_machines/circuit/coordinated_state_management.rb +3 -3
- data/lib/breaker_machines/circuit/native.rb +127 -0
- data/lib/breaker_machines/circuit/state_callbacks.rb +17 -5
- data/lib/breaker_machines/circuit/state_management.rb +1 -1
- data/lib/breaker_machines/native_extension.rb +36 -0
- data/lib/breaker_machines/native_speedup.rb +6 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +4 -1
- data/lib/breaker_machines/storage/memory.rb +4 -1
- data/lib/breaker_machines/storage/native.rb +90 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +98 -11
- data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -0
- data/sig/breaker_machines.rbs +20 -8
- metadata +99 -6
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
//! Circuit breaker implementation using state machines
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides a complete circuit breaker with state management.
|
|
4
|
+
|
|
5
|
+
use crate::{
|
|
6
|
+
StorageBackend, bulkhead::BulkheadSemaphore, callbacks::Callbacks,
|
|
7
|
+
classifier::FailureClassifier, errors::CircuitError,
|
|
8
|
+
};
|
|
9
|
+
use state_machines::state_machine;
|
|
10
|
+
use std::sync::Arc;
|
|
11
|
+
|
|
12
|
+
/// Circuit breaker configuration
|
|
13
|
+
#[derive(Debug, Clone)]
|
|
14
|
+
pub struct Config {
|
|
15
|
+
/// Number of failures required to open the circuit (absolute count)
|
|
16
|
+
/// If None, only rate-based threshold is used
|
|
17
|
+
pub failure_threshold: Option<usize>,
|
|
18
|
+
|
|
19
|
+
/// Failure rate threshold (0.0-1.0) - percentage of failures to open circuit
|
|
20
|
+
/// If None, only absolute count threshold is used
|
|
21
|
+
pub failure_rate_threshold: Option<f64>,
|
|
22
|
+
|
|
23
|
+
/// Minimum number of calls before rate-based threshold is evaluated
|
|
24
|
+
pub minimum_calls: usize,
|
|
25
|
+
|
|
26
|
+
/// Time window in seconds for counting failures
|
|
27
|
+
pub failure_window_secs: f64,
|
|
28
|
+
|
|
29
|
+
/// Timeout in seconds before transitioning from Open to HalfOpen
|
|
30
|
+
pub half_open_timeout_secs: f64,
|
|
31
|
+
|
|
32
|
+
/// Number of successes required in HalfOpen to close the circuit
|
|
33
|
+
pub success_threshold: usize,
|
|
34
|
+
|
|
35
|
+
/// Jitter factor for half_open_timeout (0.0 = no jitter, 1.0 = full jitter)
|
|
36
|
+
/// Uses chrono-machines formula: timeout * (1 - jitter + rand * jitter)
|
|
37
|
+
pub jitter_factor: f64,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl Default for Config {
|
|
41
|
+
fn default() -> Self {
|
|
42
|
+
Self {
|
|
43
|
+
failure_threshold: Some(5),
|
|
44
|
+
failure_rate_threshold: None,
|
|
45
|
+
minimum_calls: 20,
|
|
46
|
+
failure_window_secs: 60.0,
|
|
47
|
+
half_open_timeout_secs: 30.0,
|
|
48
|
+
success_threshold: 2,
|
|
49
|
+
jitter_factor: 0.0,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Context provided to fallback closures when circuit is open
|
|
55
|
+
#[derive(Debug, Clone)]
|
|
56
|
+
pub struct FallbackContext {
|
|
57
|
+
/// Circuit name
|
|
58
|
+
pub circuit_name: String,
|
|
59
|
+
/// Timestamp when circuit opened
|
|
60
|
+
pub opened_at: f64,
|
|
61
|
+
/// Current circuit state
|
|
62
|
+
pub state: &'static str,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Type alias for fallback function
|
|
66
|
+
pub type FallbackFn<T, E> = Box<dyn FnOnce(&FallbackContext) -> Result<T, E> + Send>;
|
|
67
|
+
|
|
68
|
+
/// Options for circuit breaker calls
|
|
69
|
+
pub struct CallOptions<T, E> {
|
|
70
|
+
/// Optional fallback function called when circuit is open
|
|
71
|
+
pub fallback: Option<FallbackFn<T, E>>,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
impl<T, E> Default for CallOptions<T, E> {
|
|
75
|
+
fn default() -> Self {
|
|
76
|
+
Self { fallback: None }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
impl<T, E> CallOptions<T, E> {
|
|
81
|
+
/// Create new call options with no fallback
|
|
82
|
+
pub fn new() -> Self {
|
|
83
|
+
Self::default()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Set a fallback function
|
|
87
|
+
pub fn with_fallback<F>(mut self, f: F) -> Self
|
|
88
|
+
where
|
|
89
|
+
F: FnOnce(&FallbackContext) -> Result<T, E> + Send + 'static,
|
|
90
|
+
{
|
|
91
|
+
self.fallback = Some(Box::new(f));
|
|
92
|
+
self
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Type alias for callable function
|
|
97
|
+
pub type CallableFn<T, E> = Box<dyn FnOnce() -> Result<T, E>>;
|
|
98
|
+
|
|
99
|
+
/// Trait for converting into CallOptions - allows flexible call() API
|
|
100
|
+
pub trait IntoCallOptions<T, E> {
|
|
101
|
+
fn into_call_options(self) -> (CallableFn<T, E>, CallOptions<T, E>);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Implement for plain closures (backward compatibility)
|
|
105
|
+
impl<T, E, F> IntoCallOptions<T, E> for F
|
|
106
|
+
where
|
|
107
|
+
F: FnOnce() -> Result<T, E> + 'static,
|
|
108
|
+
{
|
|
109
|
+
fn into_call_options(self) -> (Box<dyn FnOnce() -> Result<T, E>>, CallOptions<T, E>) {
|
|
110
|
+
(Box::new(self), CallOptions::default())
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Implement for (closure, CallOptions) tuple
|
|
115
|
+
impl<T, E, F> IntoCallOptions<T, E> for (F, CallOptions<T, E>)
|
|
116
|
+
where
|
|
117
|
+
F: FnOnce() -> Result<T, E> + 'static,
|
|
118
|
+
{
|
|
119
|
+
fn into_call_options(self) -> (Box<dyn FnOnce() -> Result<T, E>>, CallOptions<T, E>) {
|
|
120
|
+
(Box::new(self.0), self.1)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Circuit breaker context - shared data across all states
|
|
125
|
+
#[derive(Clone)]
|
|
126
|
+
pub struct CircuitContext {
|
|
127
|
+
pub name: String,
|
|
128
|
+
pub config: Config,
|
|
129
|
+
pub storage: Arc<dyn StorageBackend>,
|
|
130
|
+
pub failure_classifier: Option<Arc<dyn FailureClassifier>>,
|
|
131
|
+
pub bulkhead: Option<Arc<BulkheadSemaphore>>,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
impl Default for CircuitContext {
|
|
135
|
+
fn default() -> Self {
|
|
136
|
+
Self {
|
|
137
|
+
name: String::new(),
|
|
138
|
+
config: Config::default(),
|
|
139
|
+
storage: Arc::new(crate::MemoryStorage::new()),
|
|
140
|
+
failure_classifier: None,
|
|
141
|
+
bulkhead: None,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
impl std::fmt::Debug for CircuitContext {
|
|
147
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
148
|
+
f.debug_struct("CircuitContext")
|
|
149
|
+
.field("name", &self.name)
|
|
150
|
+
.field("config", &self.config)
|
|
151
|
+
.field("storage", &"<dyn StorageBackend>")
|
|
152
|
+
.field(
|
|
153
|
+
"failure_classifier",
|
|
154
|
+
&self
|
|
155
|
+
.failure_classifier
|
|
156
|
+
.as_ref()
|
|
157
|
+
.map(|_| "<dyn FailureClassifier>"),
|
|
158
|
+
)
|
|
159
|
+
.field("bulkhead", &self.bulkhead)
|
|
160
|
+
.finish()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Data specific to the Open state
|
|
165
|
+
#[derive(Debug, Clone, Default)]
|
|
166
|
+
pub struct OpenData {
|
|
167
|
+
pub opened_at: f64,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Data specific to the HalfOpen state
|
|
171
|
+
#[derive(Debug, Clone, Default)]
|
|
172
|
+
pub struct HalfOpenData {
|
|
173
|
+
pub consecutive_successes: usize,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Define the circuit breaker state machine with dynamic mode
|
|
177
|
+
state_machine! {
|
|
178
|
+
name: Circuit,
|
|
179
|
+
context: CircuitContext,
|
|
180
|
+
dynamic: true, // Enable dynamic mode for runtime state transitions
|
|
181
|
+
|
|
182
|
+
initial: Closed,
|
|
183
|
+
states: [
|
|
184
|
+
Closed,
|
|
185
|
+
Open(OpenData),
|
|
186
|
+
HalfOpen(HalfOpenData),
|
|
187
|
+
],
|
|
188
|
+
events {
|
|
189
|
+
trip {
|
|
190
|
+
guards: [should_open],
|
|
191
|
+
transition: { from: [Closed, HalfOpen], to: Open }
|
|
192
|
+
}
|
|
193
|
+
attempt_reset {
|
|
194
|
+
guards: [timeout_elapsed],
|
|
195
|
+
transition: { from: Open, to: HalfOpen }
|
|
196
|
+
}
|
|
197
|
+
close {
|
|
198
|
+
guards: [should_close],
|
|
199
|
+
transition: { from: HalfOpen, to: Closed }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Guards for dynamic mode - implemented on typestate machines
|
|
205
|
+
impl Circuit<Closed> {
|
|
206
|
+
/// Check if failure threshold is exceeded (absolute count or rate-based)
|
|
207
|
+
fn should_open(&self, ctx: &CircuitContext) -> bool {
|
|
208
|
+
let failures = ctx
|
|
209
|
+
.storage
|
|
210
|
+
.failure_count(&ctx.name, ctx.config.failure_window_secs);
|
|
211
|
+
|
|
212
|
+
// Check absolute count threshold
|
|
213
|
+
if let Some(threshold) = ctx.config.failure_threshold
|
|
214
|
+
&& failures >= threshold
|
|
215
|
+
{
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check rate-based threshold
|
|
220
|
+
if let Some(rate_threshold) = ctx.config.failure_rate_threshold {
|
|
221
|
+
let successes = ctx
|
|
222
|
+
.storage
|
|
223
|
+
.success_count(&ctx.name, ctx.config.failure_window_secs);
|
|
224
|
+
let total = failures + successes;
|
|
225
|
+
|
|
226
|
+
// Only evaluate rate if we have minimum calls
|
|
227
|
+
if total >= ctx.config.minimum_calls {
|
|
228
|
+
let failure_rate = if total > 0 {
|
|
229
|
+
failures as f64 / total as f64
|
|
230
|
+
} else {
|
|
231
|
+
0.0
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if failure_rate >= rate_threshold {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
impl Circuit<HalfOpen> {
|
|
245
|
+
/// Check if failure threshold is exceeded (absolute count or rate-based)
|
|
246
|
+
fn should_open(&self, ctx: &CircuitContext) -> bool {
|
|
247
|
+
let failures = ctx
|
|
248
|
+
.storage
|
|
249
|
+
.failure_count(&ctx.name, ctx.config.failure_window_secs);
|
|
250
|
+
|
|
251
|
+
// Check absolute count threshold
|
|
252
|
+
if let Some(threshold) = ctx.config.failure_threshold
|
|
253
|
+
&& failures >= threshold
|
|
254
|
+
{
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check rate-based threshold
|
|
259
|
+
if let Some(rate_threshold) = ctx.config.failure_rate_threshold {
|
|
260
|
+
let successes = ctx
|
|
261
|
+
.storage
|
|
262
|
+
.success_count(&ctx.name, ctx.config.failure_window_secs);
|
|
263
|
+
let total = failures + successes;
|
|
264
|
+
|
|
265
|
+
// Only evaluate rate if we have minimum calls
|
|
266
|
+
if total >= ctx.config.minimum_calls {
|
|
267
|
+
let failure_rate = if total > 0 {
|
|
268
|
+
failures as f64 / total as f64
|
|
269
|
+
} else {
|
|
270
|
+
0.0
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if failure_rate >= rate_threshold {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Check if enough successes to close circuit
|
|
283
|
+
fn should_close(&self, ctx: &CircuitContext) -> bool {
|
|
284
|
+
let data = self
|
|
285
|
+
.state_data_half_open()
|
|
286
|
+
.expect("HalfOpen state must have data");
|
|
287
|
+
data.consecutive_successes >= ctx.config.success_threshold
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
impl Circuit<Open> {
|
|
292
|
+
/// Check if timeout has elapsed for Open -> HalfOpen transition
|
|
293
|
+
fn timeout_elapsed(&self, ctx: &CircuitContext) -> bool {
|
|
294
|
+
let data = self.state_data_open().expect("Open state must have data");
|
|
295
|
+
let current_time = ctx.storage.monotonic_time();
|
|
296
|
+
let elapsed = current_time - data.opened_at;
|
|
297
|
+
|
|
298
|
+
// Apply jitter using chrono-machines if jitter_factor > 0
|
|
299
|
+
let timeout_secs = if ctx.config.jitter_factor > 0.0 {
|
|
300
|
+
let policy = chrono_machines::Policy {
|
|
301
|
+
max_attempts: 1,
|
|
302
|
+
base_delay_ms: (ctx.config.half_open_timeout_secs * 1000.0) as u64,
|
|
303
|
+
multiplier: 1.0,
|
|
304
|
+
max_delay_ms: (ctx.config.half_open_timeout_secs * 1000.0) as u64,
|
|
305
|
+
};
|
|
306
|
+
let timeout_ms = policy.calculate_delay(1, ctx.config.jitter_factor);
|
|
307
|
+
(timeout_ms as f64) / 1000.0
|
|
308
|
+
} else {
|
|
309
|
+
ctx.config.half_open_timeout_secs
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
elapsed >= timeout_secs
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Circuit breaker public API
|
|
317
|
+
pub struct CircuitBreaker {
|
|
318
|
+
machine: DynamicCircuit,
|
|
319
|
+
context: CircuitContext,
|
|
320
|
+
callbacks: Callbacks,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
impl CircuitBreaker {
|
|
324
|
+
/// Create a new circuit breaker (use builder() for more options)
|
|
325
|
+
pub fn new(name: String, config: Config) -> Self {
|
|
326
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
327
|
+
let context = CircuitContext {
|
|
328
|
+
name,
|
|
329
|
+
config,
|
|
330
|
+
storage,
|
|
331
|
+
failure_classifier: None,
|
|
332
|
+
bulkhead: None,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
let machine = DynamicCircuit::new(context.clone());
|
|
336
|
+
let callbacks = Callbacks::new();
|
|
337
|
+
|
|
338
|
+
Self {
|
|
339
|
+
machine,
|
|
340
|
+
context,
|
|
341
|
+
callbacks,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// Create a circuit breaker with custom context and callbacks (used by builder)
|
|
346
|
+
pub(crate) fn with_context_and_callbacks(
|
|
347
|
+
context: CircuitContext,
|
|
348
|
+
callbacks: Callbacks,
|
|
349
|
+
) -> Self {
|
|
350
|
+
let machine = DynamicCircuit::new(context.clone());
|
|
351
|
+
|
|
352
|
+
Self {
|
|
353
|
+
machine,
|
|
354
|
+
context,
|
|
355
|
+
callbacks,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// Create a new circuit breaker builder
|
|
360
|
+
pub fn builder(name: impl Into<String>) -> crate::builder::CircuitBuilder {
|
|
361
|
+
crate::builder::CircuitBuilder::new(name)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// Execute a fallible operation with circuit breaker protection
|
|
365
|
+
///
|
|
366
|
+
/// Accepts either:
|
|
367
|
+
/// - A plain closure: `circuit.call(|| api_request())`
|
|
368
|
+
/// - A closure with options: `circuit.call((|| api_request(), CallOptions::new().with_fallback(...)))`
|
|
369
|
+
pub fn call<I, T, E: 'static>(&mut self, input: I) -> Result<T, CircuitError<E>>
|
|
370
|
+
where
|
|
371
|
+
I: IntoCallOptions<T, E>,
|
|
372
|
+
{
|
|
373
|
+
let (f, options) = input.into_call_options();
|
|
374
|
+
|
|
375
|
+
// Try to acquire bulkhead permit if configured
|
|
376
|
+
let _guard = if let Some(bulkhead) = &self.context.bulkhead {
|
|
377
|
+
match bulkhead.try_acquire() {
|
|
378
|
+
Some(guard) => Some(guard),
|
|
379
|
+
None => {
|
|
380
|
+
return Err(CircuitError::BulkheadFull {
|
|
381
|
+
circuit: self.context.name.clone(),
|
|
382
|
+
limit: bulkhead.limit(),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
None
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Check for timeout-based Open -> HalfOpen transition
|
|
391
|
+
if self.machine.current_state() == "Open" {
|
|
392
|
+
let _ = self.machine.handle(CircuitEvent::AttemptReset);
|
|
393
|
+
if self.machine.current_state() == "HalfOpen" {
|
|
394
|
+
self.callbacks.trigger_half_open(&self.context.name);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Handle based on current state
|
|
399
|
+
match self.machine.current_state() {
|
|
400
|
+
"Open" => {
|
|
401
|
+
let opened_at = self.machine.open_data().map(|d| d.opened_at).unwrap_or(0.0);
|
|
402
|
+
|
|
403
|
+
// If fallback is provided, use it instead of returning error
|
|
404
|
+
if let Some(fallback) = options.fallback {
|
|
405
|
+
let ctx = FallbackContext {
|
|
406
|
+
circuit_name: self.context.name.clone(),
|
|
407
|
+
opened_at,
|
|
408
|
+
state: "Open",
|
|
409
|
+
};
|
|
410
|
+
return fallback(&ctx).map_err(CircuitError::Execution);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
Err(CircuitError::Open {
|
|
414
|
+
circuit: self.context.name.clone(),
|
|
415
|
+
opened_at,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
"HalfOpen" => {
|
|
419
|
+
// Check if we've reached the success threshold
|
|
420
|
+
if let Some(data) = self.machine.half_open_data()
|
|
421
|
+
&& data.consecutive_successes >= self.context.config.success_threshold
|
|
422
|
+
{
|
|
423
|
+
return Err(CircuitError::HalfOpenLimitReached {
|
|
424
|
+
circuit: self.context.name.clone(),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
self.execute_call(f)
|
|
428
|
+
}
|
|
429
|
+
_ => self.execute_call(f),
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
fn execute_call<T, E: 'static>(
|
|
434
|
+
&mut self,
|
|
435
|
+
f: Box<dyn FnOnce() -> Result<T, E>>,
|
|
436
|
+
) -> Result<T, CircuitError<E>> {
|
|
437
|
+
let start = self.context.storage.monotonic_time();
|
|
438
|
+
|
|
439
|
+
match f() {
|
|
440
|
+
Ok(val) => {
|
|
441
|
+
let duration = self.context.storage.monotonic_time() - start;
|
|
442
|
+
self.context
|
|
443
|
+
.storage
|
|
444
|
+
.record_success(&self.context.name, duration);
|
|
445
|
+
|
|
446
|
+
// Handle success in HalfOpen state
|
|
447
|
+
if self.machine.current_state() == "HalfOpen" {
|
|
448
|
+
if let Some(data) = self.machine.half_open_data_mut() {
|
|
449
|
+
data.consecutive_successes += 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Try to close the circuit
|
|
453
|
+
if self.machine.handle(CircuitEvent::Close).is_ok() {
|
|
454
|
+
self.callbacks.trigger_close(&self.context.name);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
Ok(val)
|
|
459
|
+
}
|
|
460
|
+
Err(e) => {
|
|
461
|
+
let duration = self.context.storage.monotonic_time() - start;
|
|
462
|
+
|
|
463
|
+
// Check if this error should trip the circuit using failure classifier
|
|
464
|
+
let should_trip = if let Some(classifier) = &self.context.failure_classifier {
|
|
465
|
+
let ctx = crate::classifier::FailureContext {
|
|
466
|
+
circuit_name: &self.context.name,
|
|
467
|
+
error: &e as &dyn std::any::Any,
|
|
468
|
+
duration,
|
|
469
|
+
};
|
|
470
|
+
classifier.should_trip(&ctx)
|
|
471
|
+
} else {
|
|
472
|
+
// No classifier - default behavior is to trip on all errors
|
|
473
|
+
true
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Only record failure and try to trip if classifier says we should
|
|
477
|
+
if should_trip {
|
|
478
|
+
self.context
|
|
479
|
+
.storage
|
|
480
|
+
.record_failure(&self.context.name, duration);
|
|
481
|
+
|
|
482
|
+
// Try to trip the circuit
|
|
483
|
+
let result = self.machine.handle(CircuitEvent::Trip);
|
|
484
|
+
if result.is_ok() {
|
|
485
|
+
// Transition succeeded - update opened_at timestamp
|
|
486
|
+
if let Some(data) = self.machine.open_data_mut() {
|
|
487
|
+
data.opened_at = self.context.storage.monotonic_time();
|
|
488
|
+
}
|
|
489
|
+
self.callbacks.trigger_open(&self.context.name);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
Err(CircuitError::Execution(e))
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/// Record a successful operation (for manual tracking)
|
|
499
|
+
pub fn record_success(&self, duration: f64) {
|
|
500
|
+
self.context
|
|
501
|
+
.storage
|
|
502
|
+
.record_success(&self.context.name, duration);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// Record a failed operation (for manual tracking)
|
|
506
|
+
pub fn record_failure(&self, duration: f64) {
|
|
507
|
+
self.context
|
|
508
|
+
.storage
|
|
509
|
+
.record_failure(&self.context.name, duration);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/// Check failure threshold and attempt to trip the circuit
|
|
513
|
+
/// This should be called after record_failure() when not using call()
|
|
514
|
+
pub fn check_and_trip(&mut self) -> bool {
|
|
515
|
+
self.machine.handle(CircuitEvent::Trip).is_ok()
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// Check if circuit is open
|
|
519
|
+
pub fn is_open(&self) -> bool {
|
|
520
|
+
self.machine.current_state() == "Open"
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/// Check if circuit is closed
|
|
524
|
+
pub fn is_closed(&self) -> bool {
|
|
525
|
+
self.machine.current_state() == "Closed"
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/// Get current state name
|
|
529
|
+
pub fn state_name(&self) -> &'static str {
|
|
530
|
+
self.machine.current_state()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/// Clear all events and reset circuit to Closed state
|
|
534
|
+
pub fn reset(&mut self) {
|
|
535
|
+
self.context.storage.clear(&self.context.name);
|
|
536
|
+
// Recreate machine in Closed state
|
|
537
|
+
self.machine = DynamicCircuit::new(self.context.clone());
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[cfg(test)]
|
|
542
|
+
mod tests {
|
|
543
|
+
use super::*;
|
|
544
|
+
|
|
545
|
+
#[test]
|
|
546
|
+
fn test_circuit_breaker_creation() {
|
|
547
|
+
let config = Config::default();
|
|
548
|
+
let circuit = CircuitBreaker::new("test".to_string(), config);
|
|
549
|
+
|
|
550
|
+
assert!(circuit.is_closed());
|
|
551
|
+
assert!(!circuit.is_open());
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#[test]
|
|
555
|
+
fn test_circuit_opens_after_threshold() {
|
|
556
|
+
let config = Config {
|
|
557
|
+
failure_threshold: Some(3),
|
|
558
|
+
..Default::default()
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
let mut circuit = CircuitBreaker::new("test".to_string(), config);
|
|
562
|
+
|
|
563
|
+
// Trigger failures via call() method
|
|
564
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
565
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
566
|
+
assert!(circuit.is_closed());
|
|
567
|
+
|
|
568
|
+
let _ = circuit.call(|| Err::<(), _>("error 3"));
|
|
569
|
+
assert!(circuit.is_open());
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
#[test]
|
|
573
|
+
fn test_reset_clears_state() {
|
|
574
|
+
let config = Config {
|
|
575
|
+
failure_threshold: Some(2),
|
|
576
|
+
..Default::default()
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
let mut circuit = CircuitBreaker::new("test".to_string(), config);
|
|
580
|
+
|
|
581
|
+
// Trigger failures
|
|
582
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
583
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
584
|
+
assert!(circuit.is_open());
|
|
585
|
+
|
|
586
|
+
circuit.reset();
|
|
587
|
+
assert!(circuit.is_closed());
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#[test]
|
|
591
|
+
fn test_state_machine_closed_to_open_transition() {
|
|
592
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
593
|
+
let config = Config {
|
|
594
|
+
failure_threshold: Some(3),
|
|
595
|
+
..Default::default()
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
let ctx = CircuitContext {
|
|
599
|
+
failure_classifier: None,
|
|
600
|
+
bulkhead: None,
|
|
601
|
+
name: "test_circuit".to_string(),
|
|
602
|
+
config,
|
|
603
|
+
storage: storage.clone(),
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
607
|
+
|
|
608
|
+
// Initially closed - trip should fail guard
|
|
609
|
+
let result = circuit.handle(CircuitEvent::Trip);
|
|
610
|
+
assert!(result.is_err(), "Should fail guard when below threshold");
|
|
611
|
+
|
|
612
|
+
// Record failures to exceed threshold
|
|
613
|
+
storage.record_failure("test_circuit", 0.1);
|
|
614
|
+
storage.record_failure("test_circuit", 0.1);
|
|
615
|
+
storage.record_failure("test_circuit", 0.1);
|
|
616
|
+
|
|
617
|
+
// Now trip should succeed - guards pass
|
|
618
|
+
circuit
|
|
619
|
+
.handle(CircuitEvent::Trip)
|
|
620
|
+
.expect("Should open after reaching threshold");
|
|
621
|
+
|
|
622
|
+
assert_eq!(circuit.current_state(), "Open");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#[test]
|
|
626
|
+
fn test_state_machine_open_to_half_open_transition() {
|
|
627
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
628
|
+
let config = Config {
|
|
629
|
+
failure_threshold: Some(2),
|
|
630
|
+
half_open_timeout_secs: 0.001, // Very short timeout for testing
|
|
631
|
+
..Default::default()
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
let ctx = CircuitContext {
|
|
635
|
+
failure_classifier: None,
|
|
636
|
+
bulkhead: None,
|
|
637
|
+
name: "test_circuit".to_string(),
|
|
638
|
+
config,
|
|
639
|
+
storage: storage.clone(),
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Record failures and open circuit
|
|
643
|
+
storage.record_failure("test_circuit", 0.1);
|
|
644
|
+
storage.record_failure("test_circuit", 0.1);
|
|
645
|
+
|
|
646
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
647
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
648
|
+
|
|
649
|
+
// Set opened_at timestamp
|
|
650
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
651
|
+
data.opened_at = storage.monotonic_time();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Immediately try to reset - should fail guard (timeout not elapsed)
|
|
655
|
+
let result = circuit.handle(CircuitEvent::AttemptReset);
|
|
656
|
+
assert!(
|
|
657
|
+
result.is_err(),
|
|
658
|
+
"Should fail guard when timeout not elapsed"
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// Wait for timeout
|
|
662
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
663
|
+
|
|
664
|
+
circuit
|
|
665
|
+
.handle(CircuitEvent::AttemptReset)
|
|
666
|
+
.expect("Should reset after timeout");
|
|
667
|
+
|
|
668
|
+
// Verify we're in HalfOpen state
|
|
669
|
+
assert_eq!(circuit.current_state(), "HalfOpen");
|
|
670
|
+
let data = circuit.half_open_data().expect("Should have HalfOpen data");
|
|
671
|
+
assert_eq!(data.consecutive_successes, 0);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#[test]
|
|
675
|
+
fn test_state_machine_half_open_to_closed_guard() {
|
|
676
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
677
|
+
let config = Config {
|
|
678
|
+
failure_threshold: Some(2),
|
|
679
|
+
half_open_timeout_secs: 0.001,
|
|
680
|
+
..Default::default()
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
let ctx = CircuitContext {
|
|
684
|
+
failure_classifier: None,
|
|
685
|
+
bulkhead: None,
|
|
686
|
+
name: "test_circuit".to_string(),
|
|
687
|
+
config,
|
|
688
|
+
storage: storage.clone(),
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Get to HalfOpen state
|
|
692
|
+
storage.record_failure("test_circuit", 0.1);
|
|
693
|
+
storage.record_failure("test_circuit", 0.1);
|
|
694
|
+
|
|
695
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
696
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
697
|
+
|
|
698
|
+
// Set opened_at and wait for timeout
|
|
699
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
700
|
+
data.opened_at = storage.monotonic_time();
|
|
701
|
+
}
|
|
702
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
703
|
+
|
|
704
|
+
circuit
|
|
705
|
+
.handle(CircuitEvent::AttemptReset)
|
|
706
|
+
.expect("Should reset");
|
|
707
|
+
|
|
708
|
+
// Try to close - should fail guard (not enough successes)
|
|
709
|
+
let result = circuit.handle(CircuitEvent::Close);
|
|
710
|
+
assert!(result.is_err(), "Should fail guard without successes");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#[test]
|
|
714
|
+
fn test_jitter_disabled() {
|
|
715
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
716
|
+
let config = Config {
|
|
717
|
+
failure_threshold: Some(1),
|
|
718
|
+
half_open_timeout_secs: 1.0, // 1 second timeout
|
|
719
|
+
jitter_factor: 0.0, // No jitter
|
|
720
|
+
..Default::default()
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
let ctx = CircuitContext {
|
|
724
|
+
failure_classifier: None,
|
|
725
|
+
bulkhead: None,
|
|
726
|
+
name: "test_circuit".to_string(),
|
|
727
|
+
config,
|
|
728
|
+
storage: storage.clone(),
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// Open circuit
|
|
732
|
+
storage.record_failure("test_circuit", 0.1);
|
|
733
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
734
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
735
|
+
|
|
736
|
+
// Set opened_at
|
|
737
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
738
|
+
data.opened_at = storage.monotonic_time();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Wait exactly 1 second
|
|
742
|
+
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
743
|
+
|
|
744
|
+
// Should transition to HalfOpen (no jitter = exact timeout)
|
|
745
|
+
circuit
|
|
746
|
+
.handle(CircuitEvent::AttemptReset)
|
|
747
|
+
.expect("Should reset after exact timeout");
|
|
748
|
+
assert_eq!(circuit.current_state(), "HalfOpen");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
#[test]
|
|
752
|
+
fn test_jitter_enabled() {
|
|
753
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
754
|
+
let config = Config {
|
|
755
|
+
failure_threshold: Some(1),
|
|
756
|
+
half_open_timeout_secs: 1.0,
|
|
757
|
+
jitter_factor: 0.1, // 10% jitter = 90-100% of timeout
|
|
758
|
+
..Default::default()
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
let ctx = CircuitContext {
|
|
762
|
+
failure_classifier: None,
|
|
763
|
+
bulkhead: None,
|
|
764
|
+
name: "test_circuit".to_string(),
|
|
765
|
+
config,
|
|
766
|
+
storage: storage.clone(),
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// Test multiple times to verify jitter reduces timeout
|
|
770
|
+
let mut found_early_reset = false;
|
|
771
|
+
for _ in 0..10 {
|
|
772
|
+
// Open circuit
|
|
773
|
+
storage.record_failure("test_circuit", 0.1);
|
|
774
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
775
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
776
|
+
|
|
777
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
778
|
+
data.opened_at = storage.monotonic_time();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// With 10% jitter, timeout should be 900-1000ms
|
|
782
|
+
// Try at 950ms - should sometimes succeed (jitter applied)
|
|
783
|
+
std::thread::sleep(std::time::Duration::from_millis(950));
|
|
784
|
+
|
|
785
|
+
if circuit.handle(CircuitEvent::AttemptReset).is_ok() {
|
|
786
|
+
found_early_reset = true;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
storage.clear("test_circuit");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
assert!(
|
|
794
|
+
found_early_reset,
|
|
795
|
+
"Jitter should occasionally allow reset before full timeout"
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
#[test]
|
|
800
|
+
fn test_builder_with_jitter() {
|
|
801
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
802
|
+
.failure_threshold(2)
|
|
803
|
+
.half_open_timeout_secs(1.0)
|
|
804
|
+
.jitter_factor(0.5) // 50% jitter
|
|
805
|
+
.build();
|
|
806
|
+
|
|
807
|
+
// Trigger failures
|
|
808
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
809
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
810
|
+
assert!(circuit.is_open());
|
|
811
|
+
|
|
812
|
+
// Verify jitter_factor was set
|
|
813
|
+
assert_eq!(circuit.context.config.jitter_factor, 0.5);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#[test]
|
|
817
|
+
fn test_fallback_when_open() {
|
|
818
|
+
let mut circuit = CircuitBreaker::builder("test").failure_threshold(2).build();
|
|
819
|
+
|
|
820
|
+
// Trigger failures to open circuit
|
|
821
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
822
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
823
|
+
assert!(circuit.is_open());
|
|
824
|
+
|
|
825
|
+
// Call with fallback should return fallback result
|
|
826
|
+
let result = circuit.call((
|
|
827
|
+
|| Err::<String, _>("should not execute"),
|
|
828
|
+
CallOptions::new().with_fallback(|ctx| {
|
|
829
|
+
assert_eq!(ctx.circuit_name, "test");
|
|
830
|
+
assert_eq!(ctx.state, "Open");
|
|
831
|
+
Ok("fallback response".to_string())
|
|
832
|
+
}),
|
|
833
|
+
));
|
|
834
|
+
|
|
835
|
+
assert!(result.is_ok());
|
|
836
|
+
assert_eq!(result.unwrap(), "fallback response");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
#[test]
|
|
840
|
+
fn test_fallback_error_propagation() {
|
|
841
|
+
let mut circuit = CircuitBreaker::builder("test").failure_threshold(1).build();
|
|
842
|
+
|
|
843
|
+
// Trigger failure to open circuit
|
|
844
|
+
let _ = circuit.call(|| Err::<(), _>("error"));
|
|
845
|
+
assert!(circuit.is_open());
|
|
846
|
+
|
|
847
|
+
// Fallback can also return errors
|
|
848
|
+
let result = circuit.call((
|
|
849
|
+
|| Ok::<String, _>("should not execute".to_string()),
|
|
850
|
+
CallOptions::new().with_fallback(|_ctx| Err::<String, _>("fallback error")),
|
|
851
|
+
));
|
|
852
|
+
|
|
853
|
+
assert!(result.is_err());
|
|
854
|
+
match result {
|
|
855
|
+
Err(CircuitError::Execution(e)) => assert_eq!(e, "fallback error"),
|
|
856
|
+
_ => panic!("Expected CircuitError::Execution"),
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#[test]
|
|
861
|
+
fn test_rate_based_threshold() {
|
|
862
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
863
|
+
.disable_failure_threshold() // Only use rate-based
|
|
864
|
+
.failure_rate(0.5) // 50% failure rate
|
|
865
|
+
.minimum_calls(10)
|
|
866
|
+
.build();
|
|
867
|
+
|
|
868
|
+
// First 9 calls - below minimum, circuit stays closed
|
|
869
|
+
for i in 0..9 {
|
|
870
|
+
let _result = if i % 2 == 0 {
|
|
871
|
+
circuit.call(|| Ok::<(), _>(()))
|
|
872
|
+
} else {
|
|
873
|
+
circuit.call(|| Err::<(), _>("error"))
|
|
874
|
+
};
|
|
875
|
+
// Even with failures, circuit should stay closed (below minimum calls)
|
|
876
|
+
assert!(circuit.is_closed(), "Circuit opened before minimum calls");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// 10th call - now at minimum, with 5 failures out of 10 = 50% rate
|
|
880
|
+
// This should trip the circuit
|
|
881
|
+
let _ = circuit.call(|| Err::<(), _>("error"));
|
|
882
|
+
|
|
883
|
+
// Circuit should now be open (failure rate reached threshold)
|
|
884
|
+
assert!(circuit.is_open(), "Circuit did not open at rate threshold");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#[test]
|
|
888
|
+
fn test_rate_and_absolute_threshold_both_active() {
|
|
889
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
890
|
+
.failure_threshold(3) // Absolute: 3 failures
|
|
891
|
+
.failure_rate(0.8) // Rate: 80%
|
|
892
|
+
.minimum_calls(10)
|
|
893
|
+
.build();
|
|
894
|
+
|
|
895
|
+
// Trigger 3 failures quickly - should open via absolute threshold
|
|
896
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
897
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
898
|
+
assert!(circuit.is_closed());
|
|
899
|
+
|
|
900
|
+
let _ = circuit.call(|| Err::<(), _>("error 3"));
|
|
901
|
+
assert!(
|
|
902
|
+
circuit.is_open(),
|
|
903
|
+
"Circuit did not open at absolute threshold"
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#[test]
|
|
908
|
+
fn test_minimum_calls_prevents_premature_trip() {
|
|
909
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
910
|
+
.disable_failure_threshold()
|
|
911
|
+
.failure_rate(0.5)
|
|
912
|
+
.minimum_calls(20)
|
|
913
|
+
.build();
|
|
914
|
+
|
|
915
|
+
// Record 10 failures out of 10 calls = 100% failure rate
|
|
916
|
+
for _ in 0..10 {
|
|
917
|
+
let _ = circuit.call(|| Err::<(), _>("error"));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Circuit should still be closed (below minimum_calls)
|
|
921
|
+
assert!(
|
|
922
|
+
circuit.is_closed(),
|
|
923
|
+
"Circuit opened before reaching minimum_calls"
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
#[test]
|
|
928
|
+
fn test_failure_classifier_filters_errors() {
|
|
929
|
+
use crate::classifier::PredicateClassifier;
|
|
930
|
+
|
|
931
|
+
// Classifier that only trips on "server" errors, not "client" errors
|
|
932
|
+
let classifier = Arc::new(PredicateClassifier::new(|ctx| {
|
|
933
|
+
ctx.error
|
|
934
|
+
.downcast_ref::<&str>()
|
|
935
|
+
.map(|e| e.contains("server"))
|
|
936
|
+
.unwrap_or(true)
|
|
937
|
+
}));
|
|
938
|
+
|
|
939
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
940
|
+
.failure_threshold(2)
|
|
941
|
+
.failure_classifier(classifier)
|
|
942
|
+
.build();
|
|
943
|
+
|
|
944
|
+
// Client errors should not trip circuit
|
|
945
|
+
for _ in 0..5 {
|
|
946
|
+
let _ = circuit.call(|| Err::<(), _>("client_error"));
|
|
947
|
+
}
|
|
948
|
+
assert!(
|
|
949
|
+
circuit.is_closed(),
|
|
950
|
+
"Circuit should not trip on filtered errors"
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
// Server errors should trip circuit
|
|
954
|
+
let _ = circuit.call(|| Err::<(), _>("server_error_1"));
|
|
955
|
+
let _ = circuit.call(|| Err::<(), _>("server_error_2"));
|
|
956
|
+
assert!(circuit.is_open(), "Circuit should trip on server errors");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
#[test]
|
|
960
|
+
fn test_failure_classifier_with_slow_errors() {
|
|
961
|
+
use crate::classifier::PredicateClassifier;
|
|
962
|
+
|
|
963
|
+
// Only trip on errors that take > 0.5s
|
|
964
|
+
let classifier = Arc::new(PredicateClassifier::new(|ctx| ctx.duration > 0.5));
|
|
965
|
+
|
|
966
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
967
|
+
.failure_threshold(2)
|
|
968
|
+
.failure_classifier(classifier)
|
|
969
|
+
.build();
|
|
970
|
+
|
|
971
|
+
// Fast errors don't trip (duration will be near zero in tests)
|
|
972
|
+
for _ in 0..10 {
|
|
973
|
+
let _ = circuit.call(|| Err::<(), _>("fast error"));
|
|
974
|
+
}
|
|
975
|
+
assert!(
|
|
976
|
+
circuit.is_closed(),
|
|
977
|
+
"Circuit should not trip on fast errors"
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
#[test]
|
|
982
|
+
fn test_no_classifier_default_behavior() {
|
|
983
|
+
// Without classifier, all errors should trip circuit (backward compatible)
|
|
984
|
+
let mut circuit = CircuitBreaker::builder("test").failure_threshold(3).build();
|
|
985
|
+
|
|
986
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
987
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
988
|
+
assert!(circuit.is_closed());
|
|
989
|
+
|
|
990
|
+
let _ = circuit.call(|| Err::<(), _>("error 3"));
|
|
991
|
+
assert!(
|
|
992
|
+
circuit.is_open(),
|
|
993
|
+
"All errors should trip circuit without classifier"
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
#[test]
|
|
998
|
+
fn test_classifier_with_custom_error_type() {
|
|
999
|
+
use crate::classifier::PredicateClassifier;
|
|
1000
|
+
|
|
1001
|
+
#[derive(Debug)]
|
|
1002
|
+
enum ApiError {
|
|
1003
|
+
ClientError(u16),
|
|
1004
|
+
ServerError(u16),
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Only trip on server errors (5xx), not client errors (4xx)
|
|
1008
|
+
let classifier = Arc::new(PredicateClassifier::new(|ctx| {
|
|
1009
|
+
ctx.error
|
|
1010
|
+
.downcast_ref::<ApiError>()
|
|
1011
|
+
.map(|e| match e {
|
|
1012
|
+
ApiError::ServerError(code) => *code >= 500,
|
|
1013
|
+
ApiError::ClientError(code) => *code >= 500, // Should never happen, but validates field
|
|
1014
|
+
})
|
|
1015
|
+
.unwrap_or(true)
|
|
1016
|
+
}));
|
|
1017
|
+
|
|
1018
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
1019
|
+
.failure_threshold(2)
|
|
1020
|
+
.failure_classifier(classifier)
|
|
1021
|
+
.build();
|
|
1022
|
+
|
|
1023
|
+
// Client errors (4xx) should not trip
|
|
1024
|
+
for _ in 0..10 {
|
|
1025
|
+
let _ = circuit.call(|| Err::<(), _>(ApiError::ClientError(404)));
|
|
1026
|
+
}
|
|
1027
|
+
assert!(circuit.is_closed(), "Client errors should not trip circuit");
|
|
1028
|
+
|
|
1029
|
+
// Server errors (5xx) should trip
|
|
1030
|
+
let _ = circuit.call(|| Err::<(), _>(ApiError::ServerError(500)));
|
|
1031
|
+
let _ = circuit.call(|| Err::<(), _>(ApiError::ServerError(503)));
|
|
1032
|
+
assert!(circuit.is_open(), "Server errors should trip circuit");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#[test]
|
|
1036
|
+
fn test_bulkhead_rejects_at_capacity() {
|
|
1037
|
+
let mut circuit = CircuitBreaker::builder("test").max_concurrency(2).build();
|
|
1038
|
+
|
|
1039
|
+
// First two calls should succeed (we're not actually holding them)
|
|
1040
|
+
let result1 = circuit.call(|| Ok::<_, String>("success 1"));
|
|
1041
|
+
let result2 = circuit.call(|| Ok::<_, String>("success 2"));
|
|
1042
|
+
|
|
1043
|
+
assert!(result1.is_ok());
|
|
1044
|
+
assert!(result2.is_ok());
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
#[test]
|
|
1048
|
+
fn test_bulkhead_releases_on_success() {
|
|
1049
|
+
use std::sync::{Arc, Mutex};
|
|
1050
|
+
|
|
1051
|
+
let circuit = Arc::new(Mutex::new(
|
|
1052
|
+
CircuitBreaker::builder("test").max_concurrency(1).build(),
|
|
1053
|
+
));
|
|
1054
|
+
|
|
1055
|
+
// First call acquires permit
|
|
1056
|
+
let result1 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
|
|
1057
|
+
assert!(result1.is_ok());
|
|
1058
|
+
|
|
1059
|
+
// Permit is released, second call should succeed
|
|
1060
|
+
let result2 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
|
|
1061
|
+
assert!(result2.is_ok());
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
#[test]
|
|
1065
|
+
fn test_bulkhead_releases_on_failure() {
|
|
1066
|
+
use std::sync::{Arc, Mutex};
|
|
1067
|
+
|
|
1068
|
+
let circuit = Arc::new(Mutex::new(
|
|
1069
|
+
CircuitBreaker::builder("test")
|
|
1070
|
+
.max_concurrency(1)
|
|
1071
|
+
.failure_threshold(10) // High threshold so circuit doesn't open
|
|
1072
|
+
.build(),
|
|
1073
|
+
));
|
|
1074
|
+
|
|
1075
|
+
// First call fails but releases permit
|
|
1076
|
+
let result1 = circuit.lock().unwrap().call(|| Err::<(), _>("error"));
|
|
1077
|
+
assert!(result1.is_err());
|
|
1078
|
+
|
|
1079
|
+
// Permit is released, second call should succeed
|
|
1080
|
+
let result2 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
|
|
1081
|
+
assert!(result2.is_ok());
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
#[test]
|
|
1085
|
+
fn test_bulkhead_without_limit() {
|
|
1086
|
+
let mut circuit = CircuitBreaker::builder("test").build();
|
|
1087
|
+
|
|
1088
|
+
// Without bulkhead, all calls should go through
|
|
1089
|
+
for _ in 0..100 {
|
|
1090
|
+
let result = circuit.call(|| Ok::<_, String>("success"));
|
|
1091
|
+
assert!(result.is_ok());
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
#[test]
|
|
1096
|
+
fn test_bulkhead_error_contains_limit() {
|
|
1097
|
+
// Test that bulkhead full error contains circuit name and limit
|
|
1098
|
+
// We use the underlying semaphore to simulate capacity exhaustion
|
|
1099
|
+
use std::sync::Arc;
|
|
1100
|
+
|
|
1101
|
+
let bulkhead = Arc::new(BulkheadSemaphore::new(2));
|
|
1102
|
+
|
|
1103
|
+
let mut circuit = CircuitBreaker::builder("test").build();
|
|
1104
|
+
|
|
1105
|
+
// Manually inject bulkhead into circuit context
|
|
1106
|
+
circuit.context.bulkhead = Some(bulkhead.clone());
|
|
1107
|
+
|
|
1108
|
+
// Acquire all permits directly from semaphore
|
|
1109
|
+
let _guard1 = bulkhead.try_acquire().unwrap();
|
|
1110
|
+
let _guard2 = bulkhead.try_acquire().unwrap();
|
|
1111
|
+
|
|
1112
|
+
// Now circuit call should fail with BulkheadFull
|
|
1113
|
+
let result = circuit.call(|| Ok::<_, String>("should fail"));
|
|
1114
|
+
|
|
1115
|
+
match result {
|
|
1116
|
+
Err(CircuitError::BulkheadFull {
|
|
1117
|
+
circuit: name,
|
|
1118
|
+
limit,
|
|
1119
|
+
}) => {
|
|
1120
|
+
assert_eq!(name, "test");
|
|
1121
|
+
assert_eq!(limit, 2);
|
|
1122
|
+
}
|
|
1123
|
+
_ => panic!("Expected BulkheadFull error, got: {:?}", result),
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Drop guards to release permits
|
|
1127
|
+
drop(_guard1);
|
|
1128
|
+
drop(_guard2);
|
|
1129
|
+
|
|
1130
|
+
// Now call should succeed
|
|
1131
|
+
let result = circuit.call(|| Ok::<_, String>("success"));
|
|
1132
|
+
assert!(result.is_ok());
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
#[test]
|
|
1136
|
+
fn test_bulkhead_with_circuit_breaker() {
|
|
1137
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
1138
|
+
.max_concurrency(5)
|
|
1139
|
+
.failure_threshold(3)
|
|
1140
|
+
.build();
|
|
1141
|
+
|
|
1142
|
+
// Circuit is closed, bulkhead allows calls
|
|
1143
|
+
let result = circuit.call(|| Ok::<_, String>("success"));
|
|
1144
|
+
assert!(result.is_ok());
|
|
1145
|
+
|
|
1146
|
+
// Open the circuit with failures
|
|
1147
|
+
for _ in 0..3 {
|
|
1148
|
+
let _ = circuit.call(|| Err::<(), _>("error"));
|
|
1149
|
+
}
|
|
1150
|
+
assert!(circuit.is_open());
|
|
1151
|
+
|
|
1152
|
+
// Even with bulkhead capacity, open circuit rejects calls
|
|
1153
|
+
let result = circuit.call(|| Ok::<_, String>("should fail"));
|
|
1154
|
+
assert!(matches!(result, Err(CircuitError::Open { .. })));
|
|
1155
|
+
}
|
|
1156
|
+
}
|