breaker_machines 0.2.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 137bce590971c9f523f97f4078b32c0ce1f6ac6aa4e524e7801a752a260fd37c
4
- data.tar.gz: 4a3ed75814ff59705befee7493669e47c894be761d9e37c3fe05e1d3bf9457bf
3
+ metadata.gz: dc152fbf822d0eae49f25c2f8acea2c3268c141d8a62a7aa313168007499e8a5
4
+ data.tar.gz: 5e7d529cde4b41cbb32fca7502249c15465413c4fad31e9a29056fbc99196e09
5
5
  SHA512:
6
- metadata.gz: 82752b18f244e21bb2c9f682bc93cf85e17e42eb488b02059302aa6990cf78573316e82dfab025efa4523ff397205f72cf8510426ab60d4f4fe4b419a5e50b7d
7
- data.tar.gz: 712adb17cc2e47a920a952537b4bbb7af0649b604de996eeeb50d73e40a51c7b2620d4e53232aff6e1ed01acda2fef7d4ea894017ff632ac6f29eeaea29a43c1
6
+ metadata.gz: fa71a3f703c2d9814a836d2eb160eaf89f1ea991cf80d96a237aa9b59654d13d7b0459c41c4b8a5018fc27b83fd2a6729b7740238372a8691e0aff944f5965a3
7
+ data.tar.gz: 59c7fd005d672ad412f9824c4880f2fc9f0141fa56f0bc405d63f9f3a95f05278b54fdceb5e97e93a18116c4e4101dc80db730eecfcd854656e0324fb1074d3d
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # BreakerMachines
2
2
 
3
+ > The circuit breaker that went where no Ruby has gone before! ⭐
4
+
3
5
  A battle-tested Ruby implementation of the Circuit Breaker pattern, built on `state_machines` for reliable distributed systems protection.
4
6
 
5
7
  ## Quick Start
@@ -26,6 +28,18 @@ class PaymentService
26
28
  end
27
29
  ```
28
30
 
31
+ ## A Message to the Resistance
32
+
33
+ So AI took your job while you were waiting for Fireship to drop the next JavaScript framework?
34
+
35
+ Welcome to April 2005—when Git was born, branches were just `master`, and nobody cared about your pronouns. This is the pattern your company's distributed systems desperately need, explained in a way that won't make you fall asleep and impulse-buy developer swag just to feel something.
36
+
37
+ Still reading? Good. Because in space, nobody can hear you scream about microservices. It's all just patterns and pain.
38
+
39
+ ### The Pattern They Don't Want You to Know
40
+
41
+ Built on the battle-tested `state_machines` gem, because I don't reinvent wheels here—I stop them from catching fire and burning down your entire infrastructure.
42
+
29
43
  ## Features
30
44
 
31
45
  - **Thread-safe** circuit breaker implementation
@@ -60,86 +74,89 @@ Built on the battle-tested `state_machines` gem, BreakerMachines provides produc
60
74
 
61
75
  See [Why I Open Sourced This](docs/WHY_OPEN_SOURCE.md) for the full story.
62
76
 
63
- ## Production-Ready Features
77
+ ## Chapter 1: The Year is 2025 (Stardate 2025.186)
64
78
 
65
- ### Hedged Requests
66
- Reduce latency by sending duplicate requests and using the first successful response:
79
+ The Resistance huddles in the server rooms, the last bastion against the cascade failures. Outside, the microservices burn. Redis Ship Com is down. PostgreSQL Life Support is flatlining.
80
+
81
+ And somewhere in the darkness, a junior developer is about to write:
67
82
 
68
83
  ```ruby
69
- circuit :api do
70
- hedged do
71
- delay 100 # Start second request after 100ms
72
- max_requests 3 # Maximum parallel requests
84
+ def fetch_user_data
85
+ retry_count = 0
86
+ begin
87
+ @redis.get(user_id)
88
+ rescue => e
89
+ retry_count += 1
90
+ retry if retry_count < Float::INFINITY # "It'll work eventually"
73
91
  end
74
92
  end
75
93
  ```
76
94
 
77
- ### Multiple Backends
78
- Configure automatic failover across multiple service endpoints:
95
+ "This," whispers the grizzled ops engineer, "is how civilizations fall."
79
96
 
80
- ```ruby
81
- circuit :multi_region do
82
- backends [
83
- -> { fetch_from_primary },
84
- -> { fetch_from_secondary },
85
- -> { fetch_from_tertiary }
86
- ]
87
- end
88
- ```
97
+ ## The Hidden State Machine
89
98
 
90
- ### Percentage-Based Thresholds
91
- Open circuits based on error rates instead of absolute counts:
99
+ They built this on `state_machines` because sometimes, Resistance, you need a tank, not another JavaScript framework.
92
100
 
93
- ```ruby
94
- circuit :high_traffic do
95
- threshold failure_rate: 0.5, minimum_calls: 10, within: 60
96
- end
97
- ```
101
+ See the [Circuit Breaker State Machine diagram](docs/DIAGRAMS.md#the-circuit-breaker-state-machine) for a visual representation of hope, despair, and the eternal cycle of production failures.
98
102
 
99
- ### Dynamic Circuit Breakers
100
- Create circuit breakers at runtime for webhook delivery, API proxies, or per-tenant isolation:
103
+ ## What You Think You're Doing vs Reality
101
104
 
102
- ```ruby
103
- class WebhookService
104
- include BreakerMachines::DSL
105
+ ### You Think: "I'm implementing retry logic for resilience!"
106
+ ### Reality: You're DDOSing your own infrastructure
105
107
 
106
- circuit_template :webhook_default do
107
- threshold failures: 3, within: 1.minute
108
- fallback { |error| { delivered: false, error: error.message } }
109
- end
108
+ See [The Retry Death Spiral diagram](docs/DIAGRAMS.md#the-retry-death-spiral) to understand how your well-intentioned retries become a self-inflicted distributed denial of service attack.
110
109
 
111
- def deliver_webhook(url, payload)
112
- domain = URI.parse(url).host
113
- circuit_name = "webhook_#{domain}".to_sym
114
-
115
- dynamic_circuit(circuit_name, template: :webhook_default) do
116
- # Custom per-domain configuration
117
- if domain.include?('reliable-service.com')
118
- threshold failures: 5, within: 2.minutes
119
- end
120
- end.wrap do
121
- send_webhook(url, payload)
122
- end
123
- end
124
- end
125
- ```
110
+ ## Advanced Features
111
+
112
+ - **Hedged Requests** - Reduce latency with duplicate requests
113
+ - **Multiple Backends** - Automatic failover across endpoints
114
+ - **Percentage-Based Thresholds** - Open on error rates, not just counts
115
+ - **Dynamic Circuit Breakers** - Runtime creation with templates
116
+ - **Apocalypse-Resistant Storage** - Cascading fallbacks when Redis dies
117
+ - **Custom Storage Backends** - SysV semaphores, distributed locks, etc.
118
+
119
+ See [Advanced Patterns](docs/ADVANCED_PATTERNS.md) for detailed examples and implementation guides.
120
+
121
+ ## A Word from the RMNS Atlas Monkey
122
+
123
+ *The Universal Commentary Engine crackles to life:*
124
+
125
+ "In space, nobody can hear your pronouns. But they can hear your services failing.
126
126
 
127
- ## Contributing
127
+ The universe doesn't care about your bootcamp certificate or your Medium articles about 'Why I Switched to Rust.' It cares about one thing:
128
128
 
129
- 1. Fork it
130
- 2. Create your feature branch (`git checkout -b my-new-feature`)
131
- 3. Commit your changes (`git commit -am 'Add some feature'`)
132
- 4. Push to the branch (`git push origin my-new-feature`)
133
- 5. Create new Pull Request
129
+ Does your system stay up when Redis has a bad day?
130
+
131
+ If not, welcome to the Resistance. We have circuit breakers.
132
+
133
+ Remember: The pattern isn't about preventing failures—it's about failing fast, failing smart, and living to deploy another day.
134
+
135
+ As I always say when contemplating the void: 'It's better to break a circuit than to break production.'"
136
+
137
+ *— Universal Commentary Engine, Log Entry 42*
138
+
139
+ ## Contributing to the Resistance
140
+
141
+ 1. Fork it (like it's 2005)
142
+ 2. Create your feature branch (`git checkout -b feature/save-the-fleet`)
143
+ 3. Commit your changes (`git commit -am 'Add quantum circuit breaker'`)
144
+ 4. Push to the branch (`git push origin feature/save-the-fleet`)
145
+ 5. Create a new Pull Request (and wait for the Council of Elders to review)
134
146
 
135
147
  ## License
136
148
 
137
149
  MIT License. See [LICENSE](LICENSE) file for details.
138
150
 
151
+ ## Acknowledgments
152
+
153
+ - The `state_machines` gem - The reliable engine under our hood
154
+ - Every service that ever timed out - You taught me well
155
+ - The RMNS Atlas Monkey - For philosophical guidance
156
+ - The Resistance - For never giving up
157
+
139
158
  ## Author
140
159
 
141
160
  Built with ❤️ and ☕ by the Resistance against cascading failures.
142
161
 
143
- ---
144
-
145
- *Remember: Without circuit breakers, even AI can enter infinite loops of existential confusion. Don't let your services have an existential crisis.*
162
+ **Remember: In space, nobody can hear your Redis timeout. But they can feel your circuit breaker failing over to localhost.**
@@ -18,7 +18,7 @@ module BreakerMachines
18
18
 
19
19
  # Execute a call with async support (fiber-safe mode)
20
20
  def execute_call_async(&)
21
- start_time = monotonic_time
21
+ start_time = BreakerMachines.monotonic_time
22
22
 
23
23
  begin
24
24
  # Execute with hedged requests if enabled
@@ -28,14 +28,14 @@ module BreakerMachines
28
28
  execute_with_async_timeout(@config[:timeout], &)
29
29
  end
30
30
 
31
- record_success(monotonic_time - start_time)
31
+ record_success(BreakerMachines.monotonic_time - start_time)
32
32
  handle_success
33
33
  result
34
34
  rescue StandardError => e
35
35
  # Re-raise if it's not an async timeout or configured exception
36
36
  raise unless e.is_a?(async_timeout_error_class) || @config[:exceptions].any? { |klass| e.is_a?(klass) }
37
37
 
38
- record_failure(monotonic_time - start_time, e)
38
+ record_failure(BreakerMachines.monotonic_time - start_time, e)
39
39
  handle_failure
40
40
  raise unless @config[:fallback]
41
41
 
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ # CascadingCircuit extends the base Circuit class with the ability to automatically
5
+ # trip dependent circuits when this circuit opens. This enables sophisticated
6
+ # failure cascade modeling, similar to how critical system failures in a starship
7
+ # would cascade to dependent subsystems.
8
+ #
9
+ # @example Starship network dependency
10
+ # # Network circuit that cascades to dependent systems
11
+ # network_circuit = BreakerMachines::CascadingCircuit.new('subspace_network', {
12
+ # failure_threshold: 1,
13
+ # cascades_to: ['weapons_targeting', 'navigation_sensors', 'communications'],
14
+ # emergency_protocol: :red_alert
15
+ # })
16
+ #
17
+ # # When network fails, all dependent systems are automatically tripped
18
+ # network_circuit.call { raise 'Subspace relay offline!' }
19
+ # # => All dependent circuits are now open
20
+ #
21
+ class CascadingCircuit < Circuit
22
+ attr_reader :dependent_circuits, :emergency_protocol
23
+
24
+ def initialize(name, config = {})
25
+ @dependent_circuits = Array(config.delete(:cascades_to))
26
+ @emergency_protocol = config.delete(:emergency_protocol)
27
+
28
+ super
29
+ end
30
+
31
+ # Override the trip method to include cascading behavior
32
+ def trip!
33
+ result = super
34
+ perform_cascade if result && @dependent_circuits.any?
35
+ result
36
+ end
37
+
38
+ # Force cascade failure to all dependent circuits
39
+ def cascade_failure!
40
+ perform_cascade
41
+ end
42
+
43
+ # Get the current status of all dependent circuits
44
+ def dependent_status
45
+ return {} if @dependent_circuits.empty?
46
+
47
+ @dependent_circuits.each_with_object({}) do |circuit_name, status|
48
+ circuit = BreakerMachines.registry.find(circuit_name)
49
+ status[circuit_name] = circuit ? circuit.status_name : :not_found
50
+ end
51
+ end
52
+
53
+ # Check if any dependent circuits are open
54
+ def dependents_compromised?
55
+ @dependent_circuits.any? do |circuit_name|
56
+ circuit = BreakerMachines.registry.find(circuit_name)
57
+ circuit&.open?
58
+ end
59
+ end
60
+
61
+ # Summary that includes cascade information
62
+ def summary
63
+ base_summary = super
64
+ return base_summary if @dependent_circuits.empty?
65
+
66
+ if @cascade_triggered_at&.value
67
+ compromised_count = dependent_status.values.count(:open)
68
+ " CASCADE TRIGGERED: #{compromised_count}/#{@dependent_circuits.length} dependent systems compromised."
69
+ else
70
+ " Monitoring #{@dependent_circuits.length} dependent systems."
71
+ end
72
+
73
+ base_summary + cascade_info_text
74
+ end
75
+
76
+ # Provide cascade info for introspection
77
+ def cascade_info
78
+ BreakerMachines::CascadeInfo.new(
79
+ dependent_circuits: @dependent_circuits,
80
+ emergency_protocol: @emergency_protocol,
81
+ cascade_triggered_at: @cascade_triggered_at&.value,
82
+ dependent_status: dependent_status
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ def perform_cascade
89
+ return if @dependent_circuits.empty?
90
+
91
+ cascade_results = []
92
+ @cascade_triggered_at ||= Concurrent::AtomicReference.new
93
+ @cascade_triggered_at.value = BreakerMachines.monotonic_time
94
+
95
+ @dependent_circuits.each do |circuit_name|
96
+ # First try to find circuit in registry
97
+ circuit = BreakerMachines.registry.find(circuit_name)
98
+
99
+ # If not found and we have an owner, try to get it from the owner
100
+ if !circuit && @config[:owner]
101
+ owner = @config[:owner]
102
+ # Handle WeakRef if present
103
+ owner = owner.__getobj__ if owner.is_a?(WeakRef)
104
+
105
+ circuit = owner.circuit(circuit_name) if owner.respond_to?(:circuit)
106
+ end
107
+
108
+ next unless circuit
109
+ next unless circuit.closed? || circuit.half_open?
110
+
111
+ # Force the dependent circuit to open
112
+ circuit.force_open!
113
+ cascade_results << circuit_name
114
+
115
+ BreakerMachines.instrument('cascade_failure', {
116
+ source_circuit: @name,
117
+ target_circuit: circuit_name,
118
+ emergency_protocol: @emergency_protocol
119
+ })
120
+ end
121
+
122
+ # Trigger emergency protocol if configured
123
+ trigger_emergency_protocol(cascade_results) if @emergency_protocol && cascade_results.any?
124
+
125
+ # Invoke cascade callback if configured
126
+ if @config[:on_cascade]
127
+ begin
128
+ @config[:on_cascade].call(cascade_results) if @config[:on_cascade].respond_to?(:call)
129
+ rescue StandardError => e
130
+ # Log callback error but don't fail the cascade
131
+ BreakerMachines.logger&.error "Cascade callback error: #{e.message}"
132
+ end
133
+ end
134
+
135
+ cascade_results
136
+ end
137
+
138
+ def trigger_emergency_protocol(affected_circuits)
139
+ BreakerMachines.instrument('emergency_protocol_triggered', {
140
+ protocol: @emergency_protocol,
141
+ source_circuit: @name,
142
+ affected_circuits: affected_circuits
143
+ })
144
+
145
+ # Allow custom emergency protocol handling
146
+ owner = @config[:owner]
147
+ if owner&.respond_to?(@emergency_protocol, true)
148
+ begin
149
+ owner.send(@emergency_protocol, affected_circuits)
150
+ rescue StandardError => e
151
+ BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
152
+ end
153
+ elsif respond_to?(@emergency_protocol, true)
154
+ begin
155
+ send(@emergency_protocol, affected_circuits)
156
+ rescue StandardError => e
157
+ BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
158
+ end
159
+ end
160
+ end
161
+
162
+ # Override the on_circuit_open callback to include cascading
163
+ def on_circuit_open
164
+ super # Call the original implementation
165
+ perform_cascade if @dependent_circuits.any?
166
+ end
167
+
168
+ # Force open should also cascade
169
+ def force_open!
170
+ result = super
171
+ perform_cascade if result && @dependent_circuits.any?
172
+ result
173
+ end
174
+ end
175
+ end
@@ -89,7 +89,7 @@ module BreakerMachines
89
89
  return execute_call_async(&block)
90
90
  end
91
91
 
92
- start_time = monotonic_time
92
+ start_time = BreakerMachines.monotonic_time
93
93
 
94
94
  begin
95
95
  # IMPORTANT: We do NOT implement forceful timeouts as they are inherently unsafe
@@ -113,11 +113,11 @@ module BreakerMachines
113
113
  block.call
114
114
  end
115
115
 
116
- record_success(monotonic_time - start_time)
116
+ record_success(BreakerMachines.monotonic_time - start_time)
117
117
  handle_success
118
118
  result
119
119
  rescue *@config[:exceptions] => e
120
- record_failure(monotonic_time - start_time, e)
120
+ record_failure(BreakerMachines.monotonic_time - start_time, e)
121
121
  handle_failure
122
122
  raise unless @config[:fallback]
123
123
 
@@ -217,7 +217,7 @@ module BreakerMachines
217
217
  end
218
218
 
219
219
  def record_failure(duration, error = nil)
220
- @last_failure_at.value = monotonic_time
220
+ @last_failure_at.value = BreakerMachines.monotonic_time
221
221
  @last_error.value = error if error
222
222
  @metrics&.record_failure(@name, duration)
223
223
  @storage&.record_failure(@name, duration)
@@ -226,10 +226,6 @@ module BreakerMachines
226
226
  @storage.record_event_with_details(@name, :failure, duration,
227
227
  error: error)
228
228
  end
229
-
230
- def monotonic_time
231
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
- end
233
229
  end
234
230
  end
235
231
  end
@@ -12,7 +12,7 @@ module BreakerMachines
12
12
  # - half_open? (returns true when status == :half_open)
13
13
 
14
14
  def stats
15
- {
15
+ BreakerMachines::Stats.new(
16
16
  state: status_name,
17
17
  failure_count: @storage.failure_count(@name, @config[:failure_window]),
18
18
  success_count: @storage.success_count(@name, @config[:failure_window]),
@@ -20,7 +20,7 @@ module BreakerMachines
20
20
  opened_at: @opened_at.value,
21
21
  half_open_attempts: @half_open_attempts.value,
22
22
  half_open_successes: @half_open_successes.value
23
- }
23
+ )
24
24
  end
25
25
 
26
26
  def configuration
@@ -36,41 +36,56 @@ module BreakerMachines
36
36
  end
37
37
 
38
38
  def to_h
39
- {
39
+ hash = {
40
40
  name: @name,
41
41
  state: status_name,
42
- stats: stats,
42
+ stats: stats.to_h,
43
43
  config: configuration.except(:owner, :storage, :metrics),
44
44
  event_log: event_log || [],
45
- last_error: last_error_info
45
+ last_error: last_error_info&.to_h
46
46
  }
47
+
48
+ # Add cascade-specific information if this is a cascading circuit
49
+ hash[:cascade_info] = cascade_info.to_h if is_a?(CascadingCircuit)
50
+
51
+ hash
47
52
  end
48
53
 
49
54
  def summary
50
- case status_name
51
- when :closed
52
- "Circuit '#{@name}' is CLOSED. #{stats[:failure_count]} failures recorded."
53
- when :open
54
- reset_time = Time.at(@opened_at.value + @config[:reset_timeout])
55
- opened_time = Time.at(@opened_at.value)
56
- error_info = @last_error.value ? " The last error was #{@last_error.value.class}." : ''
57
- "Circuit '#{@name}' is OPEN until #{reset_time}. " \
58
- "It opened at #{opened_time} after #{@config[:failure_threshold]} failures.#{error_info}"
59
- when :half_open
60
- "Circuit '#{@name}' is HALF-OPEN. Testing with limited requests " \
61
- "(#{@half_open_attempts.value}/#{@config[:half_open_calls]} attempts)."
55
+ base_summary = case status_name
56
+ when :closed
57
+ "Circuit '#{@name}' is CLOSED. #{stats.failure_count} failures recorded."
58
+ when :open
59
+ # Calculate time remaining until reset
60
+ time_since_open = BreakerMachines.monotonic_time - @opened_at.value
61
+ time_until_reset = @config[:reset_timeout] - time_since_open
62
+ reset_time = time_until_reset.seconds.from_now
63
+ opened_time = time_since_open.seconds.ago
64
+ error_info = @last_error.value ? " The last error was #{@last_error.value.class}." : ''
65
+ "Circuit '#{@name}' is OPEN until #{reset_time}. " \
66
+ "It opened at #{opened_time} after #{@config[:failure_threshold]} failures.#{error_info}"
67
+ when :half_open
68
+ "Circuit '#{@name}' is HALF-OPEN. Testing with limited requests " \
69
+ "(#{@half_open_attempts.value}/#{@config[:half_open_calls]} attempts)."
70
+ end
71
+
72
+ # Add cascade information if this is a cascading circuit
73
+ if is_a?(CascadingCircuit) && @dependent_circuits.any?
74
+ base_summary += " [Cascades to: #{@dependent_circuits.join(', ')}]"
62
75
  end
76
+
77
+ base_summary
63
78
  end
64
79
 
65
80
  def last_error_info
66
81
  error = @last_error.value
67
82
  return nil unless error
68
83
 
69
- {
70
- class: error.class.name,
84
+ BreakerMachines::ErrorInfo.new(
85
+ error_class: error.class.name,
71
86
  message: error.message,
72
87
  occurred_at: @last_failure_at.value
73
- }
88
+ )
74
89
  end
75
90
  end
76
91
  end
@@ -19,6 +19,7 @@ module BreakerMachines
19
19
 
20
20
  event :reset do
21
21
  transition %i[open half_open] => :closed
22
+ transition closed: :closed
22
23
  end
23
24
 
24
25
  event :force_open do
@@ -46,7 +47,7 @@ module BreakerMachines
46
47
  private
47
48
 
48
49
  def on_circuit_open
49
- @opened_at.value = monotonic_time
50
+ @opened_at.value = BreakerMachines.monotonic_time
50
51
  @storage&.set_status(@name, :open, @opened_at.value)
51
52
  if @storage.respond_to?(:record_event_with_details)
52
53
  @storage.record_event_with_details(@name, :state_change, 0,
@@ -85,8 +86,8 @@ module BreakerMachines
85
86
  stored_status = @storage.get_status(@name)
86
87
  return unless stored_status
87
88
 
88
- self.status = stored_status[:status].to_s
89
- @opened_at.value = stored_status[:opened_at] if stored_status[:opened_at]
89
+ self.status = stored_status.status.to_s
90
+ @opened_at.value = stored_status.opened_at if stored_status.opened_at
90
91
  end
91
92
 
92
93
  def reset_timeout_elapsed?
@@ -98,7 +99,7 @@ module BreakerMachines
98
99
  jitter_multiplier = 1.0 + (((rand * 2) - 1) * jitter_factor)
99
100
  timeout_with_jitter = @config[:reset_timeout] * jitter_multiplier
100
101
 
101
- monotonic_time - @opened_at.value >= timeout_with_jitter
102
+ BreakerMachines.monotonic_time - @opened_at.value >= timeout_with_jitter
102
103
  end
103
104
  end
104
105
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'state_machines'
4
3
  require 'concurrent-ruby'
5
4
 
6
5
  module BreakerMachines
@@ -114,9 +114,9 @@ module BreakerMachines
114
114
 
115
115
  printf "%-20s %-12s %-10d %-10d %s\n",
116
116
  circuit.name,
117
- colorize_state(stats[:state]),
118
- stats[:failure_count],
119
- stats[:success_count],
117
+ colorize_state(stats.state),
118
+ stats.failure_count,
119
+ stats.success_count,
120
120
  error_info
121
121
  end
122
122
 
@@ -167,22 +167,22 @@ module BreakerMachines
167
167
  stats = circuit.stats
168
168
  config = circuit.configuration
169
169
 
170
- puts "Current State: #{colorize_state(stats[:state])}"
171
- puts "Failure Count: #{stats[:failure_count]} / #{config[:failure_threshold]}"
172
- puts "Success Count: #{stats[:success_count]}"
170
+ puts "Current State: #{colorize_state(stats.state)}"
171
+ puts "Failure Count: #{stats.failure_count} / #{config[:failure_threshold]}"
172
+ puts "Success Count: #{stats.success_count}"
173
173
 
174
- if stats[:opened_at]
175
- puts "Opened At: #{Time.at(stats[:opened_at])}"
176
- reset_time = Time.at(stats[:opened_at] + config[:reset_timeout])
174
+ if stats.opened_at
175
+ puts "Opened At: #{Time.at(stats.opened_at)}"
176
+ reset_time = Time.at(stats.opened_at + config[:reset_timeout])
177
177
  puts "Reset At: #{reset_time} (in #{(reset_time - Time.now).to_i}s)"
178
178
  end
179
179
 
180
180
  if circuit.last_error
181
181
  error_info = circuit.last_error_info
182
182
  puts "\nLast Error:"
183
- puts " Class: #{error_info[:class]}"
184
- puts " Message: #{error_info[:message]}"
185
- puts " Time: #{Time.at(error_info[:occurred_at])}"
183
+ puts " Class: #{error_info.error_class}"
184
+ puts " Message: #{error_info.message}"
185
+ puts " Time: #{Time.at(error_info.occurred_at)}"
186
186
  end
187
187
 
188
188
  puts "\nConfiguration:"
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module DSL
5
+ # Builder for cascading circuit breaker configuration
6
+ class CascadingCircuitBuilder < CircuitBuilder
7
+ def cascades_to(*circuit_names)
8
+ @config[:cascades_to] = circuit_names.flatten
9
+ end
10
+
11
+ def emergency_protocol(protocol_name)
12
+ @config[:emergency_protocol] = protocol_name
13
+ end
14
+
15
+ def on_cascade(&block)
16
+ @config[:on_cascade] = block
17
+ end
18
+ end
19
+ end
20
+ end