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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/ext/breaker_machines_native/Cargo.toml +8 -0
  3. data/ext/breaker_machines_native/core/Cargo.toml +18 -0
  4. data/ext/breaker_machines_native/core/examples/basic.rs +61 -0
  5. data/ext/breaker_machines_native/core/src/builder.rs +232 -0
  6. data/ext/breaker_machines_native/core/src/bulkhead.rs +223 -0
  7. data/ext/breaker_machines_native/core/src/callbacks.rs +58 -0
  8. data/ext/breaker_machines_native/core/src/circuit.rs +1156 -0
  9. data/ext/breaker_machines_native/core/src/classifier.rs +177 -0
  10. data/ext/breaker_machines_native/core/src/errors.rs +47 -0
  11. data/ext/breaker_machines_native/core/src/lib.rs +62 -0
  12. data/ext/breaker_machines_native/core/src/storage.rs +377 -0
  13. data/ext/breaker_machines_native/extconf.rb +40 -0
  14. data/ext/breaker_machines_native/ffi/Cargo.toml +16 -0
  15. data/ext/breaker_machines_native/ffi/src/lib.rs +218 -0
  16. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/common.rs +355 -0
  17. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/dynamic.rs +276 -0
  18. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/macros.rs +49 -0
  19. 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
  20. 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
  21. 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
  22. 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
  23. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/Cargo.toml +48 -0
  24. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/examples/basic.rs +61 -0
  25. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/builder.rs +154 -0
  26. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/callbacks.rs +55 -0
  27. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/circuit.rs +607 -0
  28. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/errors.rs +38 -0
  29. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/lib.rs +58 -0
  30. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/storage.rs +377 -0
  31. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/Cargo.toml +48 -0
  32. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/examples/basic.rs +61 -0
  33. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/builder.rs +173 -0
  34. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/callbacks.rs +55 -0
  35. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/circuit.rs +855 -0
  36. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/errors.rs +38 -0
  37. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/lib.rs +58 -0
  38. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/storage.rs +377 -0
  39. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/Cargo.toml +48 -0
  40. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/examples/basic.rs +61 -0
  41. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/builder.rs +154 -0
  42. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/callbacks.rs +55 -0
  43. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/circuit.rs +607 -0
  44. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/errors.rs +38 -0
  45. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/lib.rs +58 -0
  46. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/storage.rs +377 -0
  47. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/Cargo.toml +48 -0
  48. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/examples/basic.rs +61 -0
  49. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/builder.rs +232 -0
  50. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/bulkhead.rs +223 -0
  51. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/callbacks.rs +58 -0
  52. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/circuit.rs +1156 -0
  53. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/classifier.rs +177 -0
  54. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/errors.rs +47 -0
  55. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/lib.rs +62 -0
  56. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/storage.rs +377 -0
  57. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/common.rs +355 -0
  58. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/dynamic.rs +276 -0
  59. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/macros.rs +49 -0
  60. 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
  61. data/lib/breaker_machines/circuit/async_state_management.rb +1 -1
  62. data/lib/breaker_machines/circuit/base.rb +2 -1
  63. data/lib/breaker_machines/circuit/coordinated_state_management.rb +3 -3
  64. data/lib/breaker_machines/circuit/native.rb +127 -0
  65. data/lib/breaker_machines/circuit/state_callbacks.rb +17 -5
  66. data/lib/breaker_machines/circuit/state_management.rb +1 -1
  67. data/lib/breaker_machines/native_extension.rb +36 -0
  68. data/lib/breaker_machines/native_speedup.rb +6 -0
  69. data/lib/breaker_machines/storage/bucket_memory.rb +4 -1
  70. data/lib/breaker_machines/storage/memory.rb +4 -1
  71. data/lib/breaker_machines/storage/native.rb +90 -0
  72. data/lib/breaker_machines/version.rb +1 -1
  73. data/lib/breaker_machines.rb +98 -11
  74. data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -0
  75. data/sig/breaker_machines.rbs +20 -8
  76. 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
+ }