breaker_machines 0.10.1 → 0.10.3

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/ext/breaker_machines_native/core/Cargo.toml +1 -1
  3. data/ext/breaker_machines_native/core/src/circuit.rs +1 -1
  4. data/ext/breaker_machines_native/ffi/Cargo.toml +1 -1
  5. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/Cargo.toml +48 -0
  6. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/examples/basic.rs +61 -0
  7. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/builder.rs +232 -0
  8. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/bulkhead.rs +223 -0
  9. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/callbacks.rs +147 -0
  10. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/circuit.rs +1436 -0
  11. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/classifier.rs +177 -0
  12. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/errors.rs +47 -0
  13. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/lib.rs +62 -0
  14. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.1/src/storage.rs +377 -0
  15. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/Cargo.toml +48 -0
  16. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/examples/basic.rs +61 -0
  17. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/builder.rs +232 -0
  18. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/bulkhead.rs +223 -0
  19. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/callbacks.rs +147 -0
  20. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/circuit.rs +1436 -0
  21. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/classifier.rs +177 -0
  22. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/errors.rs +47 -0
  23. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/lib.rs +62 -0
  24. data/ext/breaker_machines_native/target/package/breaker-machines-0.7.2/src/storage.rs +377 -0
  25. data/ext/breaker_machines_native/target/release/build/clang-sys-710ac1a8148f7790/out/common.rs +355 -0
  26. data/ext/breaker_machines_native/target/release/build/clang-sys-710ac1a8148f7790/out/dynamic.rs +276 -0
  27. data/ext/breaker_machines_native/target/release/build/clang-sys-710ac1a8148f7790/out/macros.rs +49 -0
  28. data/ext/breaker_machines_native/target/release/build/rb-sys-9ecc9c8203d58b3a/out/bindings-0.9.117-mri-arm64-darwin24-4.0.0.rs +8974 -0
  29. data/lib/breaker_machines/version.rb +1 -1
  30. metadata +26 -2
@@ -0,0 +1,1436 @@
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
+ self.mark_open();
486
+ } else if self.machine.current_state() == "HalfOpen" {
487
+ // Failure did not reopen the circuit; reset consecutive successes
488
+ if let Some(data) = self.machine.half_open_data_mut() {
489
+ data.consecutive_successes = 0;
490
+ }
491
+ }
492
+ }
493
+
494
+ Err(CircuitError::Execution(e))
495
+ }
496
+ }
497
+ }
498
+
499
+ /// Record a successful operation and drive HalfOpen -> Closed transitions
500
+ pub fn record_success_and_maybe_close(&mut self, duration: f64) {
501
+ self.context
502
+ .storage
503
+ .record_success(&self.context.name, duration);
504
+
505
+ if self.machine.current_state() == "HalfOpen" {
506
+ if let Some(data) = self.machine.half_open_data_mut() {
507
+ data.consecutive_successes += 1;
508
+ }
509
+
510
+ if self.machine.handle(CircuitEvent::Close).is_ok() {
511
+ self.callbacks.trigger_close(&self.context.name);
512
+ }
513
+ }
514
+ }
515
+
516
+ /// Record a failed operation and attempt to trip the circuit
517
+ pub fn record_failure_and_maybe_trip(&mut self, duration: f64) {
518
+ self.context
519
+ .storage
520
+ .record_failure(&self.context.name, duration);
521
+
522
+ let result = self.machine.handle(CircuitEvent::Trip);
523
+ if result.is_ok() {
524
+ self.mark_open();
525
+ } else if self.machine.current_state() == "HalfOpen"
526
+ && let Some(data) = self.machine.half_open_data_mut()
527
+ {
528
+ data.consecutive_successes = 0;
529
+ }
530
+ }
531
+
532
+ /// Record a successful operation (for manual tracking)
533
+ pub fn record_success(&self, duration: f64) {
534
+ self.context
535
+ .storage
536
+ .record_success(&self.context.name, duration);
537
+ }
538
+
539
+ /// Record a failed operation (for manual tracking)
540
+ pub fn record_failure(&self, duration: f64) {
541
+ self.context
542
+ .storage
543
+ .record_failure(&self.context.name, duration);
544
+ }
545
+
546
+ /// Check failure threshold and attempt to trip the circuit
547
+ /// This should be called after record_failure() when not using call()
548
+ pub fn check_and_trip(&mut self) -> bool {
549
+ if self.machine.handle(CircuitEvent::Trip).is_ok() {
550
+ self.mark_open();
551
+ true
552
+ } else {
553
+ false
554
+ }
555
+ }
556
+
557
+ /// Check if circuit is open
558
+ pub fn is_open(&self) -> bool {
559
+ self.machine.current_state() == "Open"
560
+ }
561
+
562
+ /// Check if circuit is closed
563
+ pub fn is_closed(&self) -> bool {
564
+ self.machine.current_state() == "Closed"
565
+ }
566
+
567
+ /// Get current state name
568
+ pub fn state_name(&self) -> &'static str {
569
+ self.machine.current_state()
570
+ }
571
+
572
+ /// Clear all events and reset circuit to Closed state
573
+ pub fn reset(&mut self) {
574
+ self.context.storage.clear(&self.context.name);
575
+ // Recreate machine in Closed state
576
+ self.machine = DynamicCircuit::new(self.context.clone());
577
+ }
578
+
579
+ /// Apply Open-state bookkeeping (timestamp + callback)
580
+ fn mark_open(&mut self) {
581
+ if let Some(data) = self.machine.open_data_mut() {
582
+ data.opened_at = self.context.storage.monotonic_time();
583
+ }
584
+ self.callbacks.trigger_open(&self.context.name);
585
+ }
586
+ }
587
+
588
+ #[cfg(test)]
589
+ mod tests {
590
+ use super::*;
591
+
592
+ #[test]
593
+ fn test_circuit_breaker_creation() {
594
+ let config = Config::default();
595
+ let circuit = CircuitBreaker::new("test".to_string(), config);
596
+
597
+ assert!(circuit.is_closed());
598
+ assert!(!circuit.is_open());
599
+ }
600
+
601
+ #[test]
602
+ fn test_circuit_opens_after_threshold() {
603
+ let config = Config {
604
+ failure_threshold: Some(3),
605
+ ..Default::default()
606
+ };
607
+
608
+ let mut circuit = CircuitBreaker::new("test".to_string(), config);
609
+
610
+ // Trigger failures via call() method
611
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
612
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
613
+ assert!(circuit.is_closed());
614
+
615
+ let _ = circuit.call(|| Err::<(), _>("error 3"));
616
+ assert!(circuit.is_open());
617
+ }
618
+
619
+ #[test]
620
+ fn test_reset_clears_state() {
621
+ let config = Config {
622
+ failure_threshold: Some(2),
623
+ ..Default::default()
624
+ };
625
+
626
+ let mut circuit = CircuitBreaker::new("test".to_string(), config);
627
+
628
+ // Trigger failures
629
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
630
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
631
+ assert!(circuit.is_open());
632
+
633
+ circuit.reset();
634
+ assert!(circuit.is_closed());
635
+ }
636
+
637
+ #[test]
638
+ fn test_state_machine_closed_to_open_transition() {
639
+ let storage = Arc::new(crate::MemoryStorage::new());
640
+ let config = Config {
641
+ failure_threshold: Some(3),
642
+ ..Default::default()
643
+ };
644
+
645
+ let ctx = CircuitContext {
646
+ failure_classifier: None,
647
+ bulkhead: None,
648
+ name: "test_circuit".to_string(),
649
+ config,
650
+ storage: storage.clone(),
651
+ };
652
+
653
+ let mut circuit = DynamicCircuit::new(ctx.clone());
654
+
655
+ // Initially closed - trip should fail guard
656
+ let result = circuit.handle(CircuitEvent::Trip);
657
+ assert!(result.is_err(), "Should fail guard when below threshold");
658
+
659
+ // Record failures to exceed threshold
660
+ storage.record_failure("test_circuit", 0.1);
661
+ storage.record_failure("test_circuit", 0.1);
662
+ storage.record_failure("test_circuit", 0.1);
663
+
664
+ // Now trip should succeed - guards pass
665
+ circuit
666
+ .handle(CircuitEvent::Trip)
667
+ .expect("Should open after reaching threshold");
668
+
669
+ assert_eq!(circuit.current_state(), "Open");
670
+ }
671
+
672
+ #[test]
673
+ fn test_state_machine_open_to_half_open_transition() {
674
+ let storage = Arc::new(crate::MemoryStorage::new());
675
+ let config = Config {
676
+ failure_threshold: Some(2),
677
+ half_open_timeout_secs: 0.001, // Very short timeout for testing
678
+ ..Default::default()
679
+ };
680
+
681
+ let ctx = CircuitContext {
682
+ failure_classifier: None,
683
+ bulkhead: None,
684
+ name: "test_circuit".to_string(),
685
+ config,
686
+ storage: storage.clone(),
687
+ };
688
+
689
+ // Record failures and open circuit
690
+ storage.record_failure("test_circuit", 0.1);
691
+ storage.record_failure("test_circuit", 0.1);
692
+
693
+ let mut circuit = DynamicCircuit::new(ctx.clone());
694
+ circuit.handle(CircuitEvent::Trip).expect("Should open");
695
+
696
+ // Set opened_at timestamp
697
+ if let Some(data) = circuit.open_data_mut() {
698
+ data.opened_at = storage.monotonic_time();
699
+ }
700
+
701
+ // Immediately try to reset - should fail guard (timeout not elapsed)
702
+ let result = circuit.handle(CircuitEvent::AttemptReset);
703
+ assert!(
704
+ result.is_err(),
705
+ "Should fail guard when timeout not elapsed"
706
+ );
707
+
708
+ // Wait for timeout
709
+ std::thread::sleep(std::time::Duration::from_millis(5));
710
+
711
+ circuit
712
+ .handle(CircuitEvent::AttemptReset)
713
+ .expect("Should reset after timeout");
714
+
715
+ // Verify we're in HalfOpen state
716
+ assert_eq!(circuit.current_state(), "HalfOpen");
717
+ let data = circuit.half_open_data().expect("Should have HalfOpen data");
718
+ assert_eq!(data.consecutive_successes, 0);
719
+ }
720
+
721
+ #[test]
722
+ fn test_state_machine_half_open_to_closed_guard() {
723
+ let storage = Arc::new(crate::MemoryStorage::new());
724
+ let config = Config {
725
+ failure_threshold: Some(2),
726
+ half_open_timeout_secs: 0.001,
727
+ ..Default::default()
728
+ };
729
+
730
+ let ctx = CircuitContext {
731
+ failure_classifier: None,
732
+ bulkhead: None,
733
+ name: "test_circuit".to_string(),
734
+ config,
735
+ storage: storage.clone(),
736
+ };
737
+
738
+ // Get to HalfOpen state
739
+ storage.record_failure("test_circuit", 0.1);
740
+ storage.record_failure("test_circuit", 0.1);
741
+
742
+ let mut circuit = DynamicCircuit::new(ctx.clone());
743
+ circuit.handle(CircuitEvent::Trip).expect("Should open");
744
+
745
+ // Set opened_at and wait for timeout
746
+ if let Some(data) = circuit.open_data_mut() {
747
+ data.opened_at = storage.monotonic_time();
748
+ }
749
+ std::thread::sleep(std::time::Duration::from_millis(5));
750
+
751
+ circuit
752
+ .handle(CircuitEvent::AttemptReset)
753
+ .expect("Should reset");
754
+
755
+ // Try to close - should fail guard (not enough successes)
756
+ let result = circuit.handle(CircuitEvent::Close);
757
+ assert!(result.is_err(), "Should fail guard without successes");
758
+ }
759
+
760
+ #[test]
761
+ fn test_jitter_disabled() {
762
+ let storage = Arc::new(crate::MemoryStorage::new());
763
+ let config = Config {
764
+ failure_threshold: Some(1),
765
+ half_open_timeout_secs: 1.0, // 1 second timeout
766
+ jitter_factor: 0.0, // No jitter
767
+ ..Default::default()
768
+ };
769
+
770
+ let ctx = CircuitContext {
771
+ failure_classifier: None,
772
+ bulkhead: None,
773
+ name: "test_circuit".to_string(),
774
+ config,
775
+ storage: storage.clone(),
776
+ };
777
+
778
+ // Open circuit
779
+ storage.record_failure("test_circuit", 0.1);
780
+ let mut circuit = DynamicCircuit::new(ctx.clone());
781
+ circuit.handle(CircuitEvent::Trip).expect("Should open");
782
+
783
+ // Set opened_at
784
+ if let Some(data) = circuit.open_data_mut() {
785
+ data.opened_at = storage.monotonic_time();
786
+ }
787
+
788
+ // Wait exactly 1 second
789
+ std::thread::sleep(std::time::Duration::from_secs(1));
790
+
791
+ // Should transition to HalfOpen (no jitter = exact timeout)
792
+ circuit
793
+ .handle(CircuitEvent::AttemptReset)
794
+ .expect("Should reset after exact timeout");
795
+ assert_eq!(circuit.current_state(), "HalfOpen");
796
+ }
797
+
798
+ #[test]
799
+ fn test_jitter_enabled() {
800
+ let storage = Arc::new(crate::MemoryStorage::new());
801
+ let config = Config {
802
+ failure_threshold: Some(1),
803
+ half_open_timeout_secs: 1.0,
804
+ jitter_factor: 0.1, // 10% jitter = 90-100% of timeout
805
+ ..Default::default()
806
+ };
807
+
808
+ let ctx = CircuitContext {
809
+ failure_classifier: None,
810
+ bulkhead: None,
811
+ name: "test_circuit".to_string(),
812
+ config,
813
+ storage: storage.clone(),
814
+ };
815
+
816
+ // Test multiple times to verify jitter reduces timeout
817
+ let mut found_early_reset = false;
818
+ for _ in 0..10 {
819
+ // Open circuit
820
+ storage.record_failure("test_circuit", 0.1);
821
+ let mut circuit = DynamicCircuit::new(ctx.clone());
822
+ circuit.handle(CircuitEvent::Trip).expect("Should open");
823
+
824
+ if let Some(data) = circuit.open_data_mut() {
825
+ data.opened_at = storage.monotonic_time();
826
+ }
827
+
828
+ // With 10% jitter, timeout should be 900-1000ms
829
+ // Try at 950ms - should sometimes succeed (jitter applied)
830
+ std::thread::sleep(std::time::Duration::from_millis(950));
831
+
832
+ if circuit.handle(CircuitEvent::AttemptReset).is_ok() {
833
+ found_early_reset = true;
834
+ break;
835
+ }
836
+
837
+ storage.clear("test_circuit");
838
+ }
839
+
840
+ assert!(
841
+ found_early_reset,
842
+ "Jitter should occasionally allow reset before full timeout"
843
+ );
844
+ }
845
+
846
+ #[test]
847
+ fn test_builder_with_jitter() {
848
+ let mut circuit = CircuitBreaker::builder("test")
849
+ .failure_threshold(2)
850
+ .half_open_timeout_secs(1.0)
851
+ .jitter_factor(0.5) // 50% jitter
852
+ .build();
853
+
854
+ // Trigger failures
855
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
856
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
857
+ assert!(circuit.is_open());
858
+
859
+ // Verify jitter_factor was set
860
+ assert_eq!(circuit.context.config.jitter_factor, 0.5);
861
+ }
862
+
863
+ #[test]
864
+ fn test_fallback_when_open() {
865
+ let mut circuit = CircuitBreaker::builder("test").failure_threshold(2).build();
866
+
867
+ // Trigger failures to open circuit
868
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
869
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
870
+ assert!(circuit.is_open());
871
+
872
+ // Call with fallback should return fallback result
873
+ let result = circuit.call((
874
+ || Err::<String, _>("should not execute"),
875
+ CallOptions::new().with_fallback(|ctx| {
876
+ assert_eq!(ctx.circuit_name, "test");
877
+ assert_eq!(ctx.state, "Open");
878
+ Ok("fallback response".to_string())
879
+ }),
880
+ ));
881
+
882
+ assert!(result.is_ok());
883
+ assert_eq!(result.unwrap(), "fallback response");
884
+ }
885
+
886
+ #[test]
887
+ fn test_fallback_error_propagation() {
888
+ let mut circuit = CircuitBreaker::builder("test").failure_threshold(1).build();
889
+
890
+ // Trigger failure to open circuit
891
+ let _ = circuit.call(|| Err::<(), _>("error"));
892
+ assert!(circuit.is_open());
893
+
894
+ // Fallback can also return errors
895
+ let result = circuit.call((
896
+ || Ok::<String, _>("should not execute".to_string()),
897
+ CallOptions::new().with_fallback(|_ctx| Err::<String, _>("fallback error")),
898
+ ));
899
+
900
+ assert!(result.is_err());
901
+ match result {
902
+ Err(CircuitError::Execution(e)) => assert_eq!(e, "fallback error"),
903
+ _ => panic!("Expected CircuitError::Execution"),
904
+ }
905
+ }
906
+
907
+ #[test]
908
+ fn test_rate_based_threshold() {
909
+ let mut circuit = CircuitBreaker::builder("test")
910
+ .disable_failure_threshold() // Only use rate-based
911
+ .failure_rate(0.5) // 50% failure rate
912
+ .minimum_calls(10)
913
+ .build();
914
+
915
+ // First 9 calls - below minimum, circuit stays closed
916
+ for i in 0..9 {
917
+ let _result = if i % 2 == 0 {
918
+ circuit.call(|| Ok::<(), _>(()))
919
+ } else {
920
+ circuit.call(|| Err::<(), _>("error"))
921
+ };
922
+ // Even with failures, circuit should stay closed (below minimum calls)
923
+ assert!(circuit.is_closed(), "Circuit opened before minimum calls");
924
+ }
925
+
926
+ // 10th call - now at minimum, with 5 failures out of 10 = 50% rate
927
+ // This should trip the circuit
928
+ let _ = circuit.call(|| Err::<(), _>("error"));
929
+
930
+ // Circuit should now be open (failure rate reached threshold)
931
+ assert!(circuit.is_open(), "Circuit did not open at rate threshold");
932
+ }
933
+
934
+ #[test]
935
+ fn test_rate_and_absolute_threshold_both_active() {
936
+ let mut circuit = CircuitBreaker::builder("test")
937
+ .failure_threshold(3) // Absolute: 3 failures
938
+ .failure_rate(0.8) // Rate: 80%
939
+ .minimum_calls(10)
940
+ .build();
941
+
942
+ // Trigger 3 failures quickly - should open via absolute threshold
943
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
944
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
945
+ assert!(circuit.is_closed());
946
+
947
+ let _ = circuit.call(|| Err::<(), _>("error 3"));
948
+ assert!(
949
+ circuit.is_open(),
950
+ "Circuit did not open at absolute threshold"
951
+ );
952
+ }
953
+
954
+ #[test]
955
+ fn test_minimum_calls_prevents_premature_trip() {
956
+ let mut circuit = CircuitBreaker::builder("test")
957
+ .disable_failure_threshold()
958
+ .failure_rate(0.5)
959
+ .minimum_calls(20)
960
+ .build();
961
+
962
+ // Record 10 failures out of 10 calls = 100% failure rate
963
+ for _ in 0..10 {
964
+ let _ = circuit.call(|| Err::<(), _>("error"));
965
+ }
966
+
967
+ // Circuit should still be closed (below minimum_calls)
968
+ assert!(
969
+ circuit.is_closed(),
970
+ "Circuit opened before reaching minimum_calls"
971
+ );
972
+ }
973
+
974
+ #[test]
975
+ fn test_failure_classifier_filters_errors() {
976
+ use crate::classifier::PredicateClassifier;
977
+
978
+ // Classifier that only trips on "server" errors, not "client" errors
979
+ let classifier = Arc::new(PredicateClassifier::new(|ctx| {
980
+ ctx.error
981
+ .downcast_ref::<&str>()
982
+ .map(|e| e.contains("server"))
983
+ .unwrap_or(true)
984
+ }));
985
+
986
+ let mut circuit = CircuitBreaker::builder("test")
987
+ .failure_threshold(2)
988
+ .failure_classifier(classifier)
989
+ .build();
990
+
991
+ // Client errors should not trip circuit
992
+ for _ in 0..5 {
993
+ let _ = circuit.call(|| Err::<(), _>("client_error"));
994
+ }
995
+ assert!(
996
+ circuit.is_closed(),
997
+ "Circuit should not trip on filtered errors"
998
+ );
999
+
1000
+ // Server errors should trip circuit
1001
+ let _ = circuit.call(|| Err::<(), _>("server_error_1"));
1002
+ let _ = circuit.call(|| Err::<(), _>("server_error_2"));
1003
+ assert!(circuit.is_open(), "Circuit should trip on server errors");
1004
+ }
1005
+
1006
+ #[test]
1007
+ fn test_failure_classifier_with_slow_errors() {
1008
+ use crate::classifier::PredicateClassifier;
1009
+
1010
+ // Only trip on errors that take > 0.5s
1011
+ let classifier = Arc::new(PredicateClassifier::new(|ctx| ctx.duration > 0.5));
1012
+
1013
+ let mut circuit = CircuitBreaker::builder("test")
1014
+ .failure_threshold(2)
1015
+ .failure_classifier(classifier)
1016
+ .build();
1017
+
1018
+ // Fast errors don't trip (duration will be near zero in tests)
1019
+ for _ in 0..10 {
1020
+ let _ = circuit.call(|| Err::<(), _>("fast error"));
1021
+ }
1022
+ assert!(
1023
+ circuit.is_closed(),
1024
+ "Circuit should not trip on fast errors"
1025
+ );
1026
+ }
1027
+
1028
+ #[test]
1029
+ fn test_no_classifier_default_behavior() {
1030
+ // Without classifier, all errors should trip circuit (backward compatible)
1031
+ let mut circuit = CircuitBreaker::builder("test").failure_threshold(3).build();
1032
+
1033
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
1034
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
1035
+ assert!(circuit.is_closed());
1036
+
1037
+ let _ = circuit.call(|| Err::<(), _>("error 3"));
1038
+ assert!(
1039
+ circuit.is_open(),
1040
+ "All errors should trip circuit without classifier"
1041
+ );
1042
+ }
1043
+
1044
+ #[test]
1045
+ fn test_classifier_with_custom_error_type() {
1046
+ use crate::classifier::PredicateClassifier;
1047
+
1048
+ #[derive(Debug)]
1049
+ enum ApiError {
1050
+ ClientError(u16),
1051
+ ServerError(u16),
1052
+ }
1053
+
1054
+ // Only trip on server errors (5xx), not client errors (4xx)
1055
+ let classifier = Arc::new(PredicateClassifier::new(|ctx| {
1056
+ ctx.error
1057
+ .downcast_ref::<ApiError>()
1058
+ .map(|e| match e {
1059
+ ApiError::ServerError(code) => *code >= 500,
1060
+ ApiError::ClientError(code) => *code >= 500, // Should never happen, but validates field
1061
+ })
1062
+ .unwrap_or(true)
1063
+ }));
1064
+
1065
+ let mut circuit = CircuitBreaker::builder("test")
1066
+ .failure_threshold(2)
1067
+ .failure_classifier(classifier)
1068
+ .build();
1069
+
1070
+ // Client errors (4xx) should not trip
1071
+ for _ in 0..10 {
1072
+ let _ = circuit.call(|| Err::<(), _>(ApiError::ClientError(404)));
1073
+ }
1074
+ assert!(circuit.is_closed(), "Client errors should not trip circuit");
1075
+
1076
+ // Server errors (5xx) should trip
1077
+ let _ = circuit.call(|| Err::<(), _>(ApiError::ServerError(500)));
1078
+ let _ = circuit.call(|| Err::<(), _>(ApiError::ServerError(503)));
1079
+ assert!(circuit.is_open(), "Server errors should trip circuit");
1080
+ }
1081
+
1082
+ #[test]
1083
+ fn test_bulkhead_rejects_at_capacity() {
1084
+ let mut circuit = CircuitBreaker::builder("test").max_concurrency(2).build();
1085
+
1086
+ // First two calls should succeed (we're not actually holding them)
1087
+ let result1 = circuit.call(|| Ok::<_, String>("success 1"));
1088
+ let result2 = circuit.call(|| Ok::<_, String>("success 2"));
1089
+
1090
+ assert!(result1.is_ok());
1091
+ assert!(result2.is_ok());
1092
+ }
1093
+
1094
+ #[test]
1095
+ fn test_bulkhead_releases_on_success() {
1096
+ use std::sync::{Arc, Mutex};
1097
+
1098
+ let circuit = Arc::new(Mutex::new(
1099
+ CircuitBreaker::builder("test").max_concurrency(1).build(),
1100
+ ));
1101
+
1102
+ // First call acquires permit
1103
+ let result1 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
1104
+ assert!(result1.is_ok());
1105
+
1106
+ // Permit is released, second call should succeed
1107
+ let result2 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
1108
+ assert!(result2.is_ok());
1109
+ }
1110
+
1111
+ #[test]
1112
+ fn test_bulkhead_releases_on_failure() {
1113
+ use std::sync::{Arc, Mutex};
1114
+
1115
+ let circuit = Arc::new(Mutex::new(
1116
+ CircuitBreaker::builder("test")
1117
+ .max_concurrency(1)
1118
+ .failure_threshold(10) // High threshold so circuit doesn't open
1119
+ .build(),
1120
+ ));
1121
+
1122
+ // First call fails but releases permit
1123
+ let result1 = circuit.lock().unwrap().call(|| Err::<(), _>("error"));
1124
+ assert!(result1.is_err());
1125
+
1126
+ // Permit is released, second call should succeed
1127
+ let result2 = circuit.lock().unwrap().call(|| Ok::<_, String>("success"));
1128
+ assert!(result2.is_ok());
1129
+ }
1130
+
1131
+ #[test]
1132
+ fn test_bulkhead_without_limit() {
1133
+ let mut circuit = CircuitBreaker::builder("test").build();
1134
+
1135
+ // Without bulkhead, all calls should go through
1136
+ for _ in 0..100 {
1137
+ let result = circuit.call(|| Ok::<_, String>("success"));
1138
+ assert!(result.is_ok());
1139
+ }
1140
+ }
1141
+
1142
+ #[test]
1143
+ fn test_bulkhead_error_contains_limit() {
1144
+ // Test that bulkhead full error contains circuit name and limit
1145
+ // We use the underlying semaphore to simulate capacity exhaustion
1146
+ use std::sync::Arc;
1147
+
1148
+ let bulkhead = Arc::new(BulkheadSemaphore::new(2));
1149
+
1150
+ let mut circuit = CircuitBreaker::builder("test").build();
1151
+
1152
+ // Manually inject bulkhead into circuit context
1153
+ circuit.context.bulkhead = Some(bulkhead.clone());
1154
+
1155
+ // Acquire all permits directly from semaphore
1156
+ let _guard1 = bulkhead.try_acquire().unwrap();
1157
+ let _guard2 = bulkhead.try_acquire().unwrap();
1158
+
1159
+ // Now circuit call should fail with BulkheadFull
1160
+ let result = circuit.call(|| Ok::<_, String>("should fail"));
1161
+
1162
+ match result {
1163
+ Err(CircuitError::BulkheadFull {
1164
+ circuit: name,
1165
+ limit,
1166
+ }) => {
1167
+ assert_eq!(name, "test");
1168
+ assert_eq!(limit, 2);
1169
+ }
1170
+ _ => panic!("Expected BulkheadFull error, got: {:?}", result),
1171
+ }
1172
+
1173
+ // Drop guards to release permits
1174
+ drop(_guard1);
1175
+ drop(_guard2);
1176
+
1177
+ // Now call should succeed
1178
+ let result = circuit.call(|| Ok::<_, String>("success"));
1179
+ assert!(result.is_ok());
1180
+ }
1181
+
1182
+ #[test]
1183
+ fn test_bulkhead_with_circuit_breaker() {
1184
+ let mut circuit = CircuitBreaker::builder("test")
1185
+ .max_concurrency(5)
1186
+ .failure_threshold(3)
1187
+ .build();
1188
+
1189
+ // Circuit is closed, bulkhead allows calls
1190
+ let result = circuit.call(|| Ok::<_, String>("success"));
1191
+ assert!(result.is_ok());
1192
+
1193
+ // Open the circuit with failures
1194
+ for _ in 0..3 {
1195
+ let _ = circuit.call(|| Err::<(), _>("error"));
1196
+ }
1197
+ assert!(circuit.is_open());
1198
+
1199
+ // Even with bulkhead capacity, open circuit rejects calls
1200
+ let result = circuit.call(|| Ok::<_, String>("should fail"));
1201
+ assert!(matches!(result, Err(CircuitError::Open { .. })));
1202
+ }
1203
+
1204
+ #[test]
1205
+ fn test_check_and_trip_sets_opened_at_and_callback() {
1206
+ use std::sync::atomic::{AtomicBool, Ordering};
1207
+
1208
+ let opened = Arc::new(AtomicBool::new(false));
1209
+ let opened_clone = opened.clone();
1210
+
1211
+ let mut circuit = CircuitBreaker::builder("test")
1212
+ .failure_threshold(1)
1213
+ .on_open(move |_name| {
1214
+ opened_clone.store(true, Ordering::SeqCst);
1215
+ })
1216
+ .build();
1217
+
1218
+ circuit.record_failure(0.1);
1219
+ let tripped = circuit.check_and_trip();
1220
+
1221
+ assert!(tripped, "Trip should succeed");
1222
+ assert!(circuit.is_open(), "Circuit should be open after trip");
1223
+
1224
+ let opened_at = circuit
1225
+ .machine
1226
+ .open_data()
1227
+ .expect("Open data should be present")
1228
+ .opened_at;
1229
+
1230
+ assert!(opened_at > 0.0, "opened_at should be set");
1231
+ assert!(
1232
+ opened.load(Ordering::SeqCst),
1233
+ "on_open callback should fire"
1234
+ );
1235
+ }
1236
+
1237
+ #[test]
1238
+ fn test_half_open_failure_resets_consecutive_successes() {
1239
+ let mut circuit = CircuitBreaker::builder("test")
1240
+ .failure_threshold(2)
1241
+ .half_open_timeout_secs(0.001)
1242
+ .success_threshold(2)
1243
+ .build();
1244
+
1245
+ // Open the circuit
1246
+ let _ = circuit.call(|| Err::<(), _>("error 1"));
1247
+ let _ = circuit.call(|| Err::<(), _>("error 2"));
1248
+ assert!(circuit.is_open());
1249
+
1250
+ // Move to HalfOpen
1251
+ if let Some(data) = circuit.machine.open_data_mut() {
1252
+ data.opened_at = circuit.context.storage.monotonic_time();
1253
+ }
1254
+ std::thread::sleep(std::time::Duration::from_millis(2));
1255
+ circuit
1256
+ .machine
1257
+ .handle(CircuitEvent::AttemptReset)
1258
+ .expect("Should transition to HalfOpen");
1259
+ assert_eq!(circuit.machine.current_state(), "HalfOpen");
1260
+
1261
+ // Clear counts to simulate expired failure window
1262
+ circuit.context.storage.clear("test");
1263
+
1264
+ // First success increments consecutive count
1265
+ let _ = circuit.call(|| Ok::<_, String>("ok"));
1266
+ assert_eq!(
1267
+ circuit
1268
+ .machine
1269
+ .half_open_data()
1270
+ .expect("HalfOpen data")
1271
+ .consecutive_successes,
1272
+ 1
1273
+ );
1274
+
1275
+ // Failure below threshold should not reopen circuit but should reset counter
1276
+ let _ = circuit.call(|| Err::<(), _>("fail"));
1277
+ assert_eq!(circuit.machine.current_state(), "HalfOpen");
1278
+ assert_eq!(
1279
+ circuit
1280
+ .machine
1281
+ .half_open_data()
1282
+ .expect("HalfOpen data")
1283
+ .consecutive_successes,
1284
+ 0
1285
+ );
1286
+
1287
+ // Next success starts count from 1 again
1288
+ let _ = circuit.call(|| Ok::<_, String>("ok2"));
1289
+ assert_eq!(
1290
+ circuit
1291
+ .machine
1292
+ .half_open_data()
1293
+ .expect("HalfOpen data")
1294
+ .consecutive_successes,
1295
+ 1
1296
+ );
1297
+ }
1298
+
1299
+ #[test]
1300
+ fn test_jitter_distribution_within_bounds() {
1301
+ // Test that jitter produces values within expected bounds
1302
+ // With 25% jitter on 1000ms base, expect 750-1000ms range
1303
+ let storage = Arc::new(crate::MemoryStorage::new());
1304
+ let base_timeout = 1.0; // 1 second
1305
+ let jitter_factor = 0.25;
1306
+
1307
+ let config = Config {
1308
+ failure_threshold: Some(1),
1309
+ half_open_timeout_secs: base_timeout,
1310
+ jitter_factor,
1311
+ ..Default::default()
1312
+ };
1313
+
1314
+ let ctx = CircuitContext {
1315
+ failure_classifier: None,
1316
+ bulkhead: None,
1317
+ name: "jitter_test".to_string(),
1318
+ config,
1319
+ storage: storage.clone(),
1320
+ };
1321
+
1322
+ // Run 50 iterations and collect timeout values
1323
+ let mut min_seen = f64::MAX;
1324
+ let mut max_seen = f64::MIN;
1325
+
1326
+ for _ in 0..50 {
1327
+ storage.record_failure("jitter_test", 0.1);
1328
+ let mut circuit = DynamicCircuit::new(ctx.clone());
1329
+ circuit.handle(CircuitEvent::Trip).expect("Should open");
1330
+
1331
+ if let Some(data) = circuit.open_data_mut() {
1332
+ data.opened_at = storage.monotonic_time();
1333
+ }
1334
+
1335
+ // Calculate what the jittered timeout would be
1336
+ let policy = chrono_machines::Policy {
1337
+ max_attempts: 1,
1338
+ base_delay_ms: (base_timeout * 1000.0) as u64,
1339
+ multiplier: 1.0,
1340
+ max_delay_ms: (base_timeout * 1000.0) as u64,
1341
+ };
1342
+ let timeout_ms = policy.calculate_delay(1, jitter_factor);
1343
+ let timeout_secs = (timeout_ms as f64) / 1000.0;
1344
+
1345
+ min_seen = min_seen.min(timeout_secs);
1346
+ max_seen = max_seen.max(timeout_secs);
1347
+
1348
+ storage.clear("jitter_test");
1349
+ }
1350
+
1351
+ // With 25% jitter, minimum should be ~0.75s (75% of base)
1352
+ // Maximum should be ~1.0s (100% of base)
1353
+ let min_expected = base_timeout * (1.0 - jitter_factor);
1354
+ let max_expected = base_timeout;
1355
+
1356
+ assert!(
1357
+ min_seen >= min_expected - 0.01,
1358
+ "Minimum jittered timeout {} should be >= {}",
1359
+ min_seen,
1360
+ min_expected
1361
+ );
1362
+ assert!(
1363
+ max_seen <= max_expected + 0.01,
1364
+ "Maximum jittered timeout {} should be <= {}",
1365
+ max_seen,
1366
+ max_expected
1367
+ );
1368
+ }
1369
+
1370
+ #[test]
1371
+ fn test_jitter_produces_variance() {
1372
+ // Test that jitter actually produces different values (not all same)
1373
+ let storage = Arc::new(crate::MemoryStorage::new());
1374
+
1375
+ let config = Config {
1376
+ failure_threshold: Some(1),
1377
+ half_open_timeout_secs: 1.0,
1378
+ jitter_factor: 0.5, // 50% jitter for more variance
1379
+ ..Default::default()
1380
+ };
1381
+
1382
+ let _ctx = CircuitContext {
1383
+ failure_classifier: None,
1384
+ bulkhead: None,
1385
+ name: "jitter_variance".to_string(),
1386
+ config,
1387
+ storage: storage.clone(),
1388
+ };
1389
+
1390
+ let mut values = std::collections::HashSet::new();
1391
+
1392
+ for _ in 0..20 {
1393
+ let policy = chrono_machines::Policy {
1394
+ max_attempts: 1,
1395
+ base_delay_ms: 1000,
1396
+ multiplier: 1.0,
1397
+ max_delay_ms: 1000,
1398
+ };
1399
+ let timeout_ms = policy.calculate_delay(1, 0.5);
1400
+ values.insert(timeout_ms);
1401
+ }
1402
+
1403
+ // With 50% jitter over 20 iterations, we should see at least 2 different values
1404
+ // (statistically, seeing all same values is extremely unlikely)
1405
+ assert!(
1406
+ values.len() >= 2,
1407
+ "Jitter should produce variance, got {} unique values",
1408
+ values.len()
1409
+ );
1410
+ }
1411
+
1412
+ #[test]
1413
+ fn test_zero_jitter_produces_constant_timeout() {
1414
+ // Test that 0% jitter always produces the same timeout
1415
+ let policy = chrono_machines::Policy {
1416
+ max_attempts: 1,
1417
+ base_delay_ms: 1000,
1418
+ multiplier: 1.0,
1419
+ max_delay_ms: 1000,
1420
+ };
1421
+
1422
+ let mut values = std::collections::HashSet::new();
1423
+
1424
+ for _ in 0..10 {
1425
+ let timeout_ms = policy.calculate_delay(1, 0.0);
1426
+ values.insert(timeout_ms);
1427
+ }
1428
+
1429
+ assert_eq!(
1430
+ values.len(),
1431
+ 1,
1432
+ "Zero jitter should produce constant timeout"
1433
+ );
1434
+ assert!(values.contains(&1000), "Timeout should be exactly 1000ms");
1435
+ }
1436
+ }