breaker_machines 0.3.0 → 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 +4 -4
- data/lib/breaker_machines/async_support.rb +3 -3
- data/lib/breaker_machines/cascading_circuit.rb +175 -0
- data/lib/breaker_machines/circuit/execution.rb +4 -8
- data/lib/breaker_machines/circuit/introspection.rb +35 -20
- data/lib/breaker_machines/circuit/state_management.rb +5 -4
- data/lib/breaker_machines/circuit.rb +0 -1
- data/lib/breaker_machines/console.rb +12 -12
- data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
- data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
- data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
- data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
- data/lib/breaker_machines/dsl.rb +26 -239
- data/lib/breaker_machines/registry.rb +3 -3
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +3 -3
- data/lib/breaker_machines/storage/cache.rb +3 -3
- data/lib/breaker_machines/storage/fallback_chain.rb +56 -70
- data/lib/breaker_machines/storage/memory.rb +3 -3
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +12 -0
- metadata +13 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dc152fbf822d0eae49f25c2f8acea2c3268c141d8a62a7aa313168007499e8a5
|
4
|
+
data.tar.gz: 5e7d529cde4b41cbb32fca7502249c15465413c4fad31e9a29056fbc99196e09
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fa71a3f703c2d9814a836d2eb160eaf89f1ea991cf80d96a237aa9b59654d13d7b0459c41c4b8a5018fc27b83fd2a6729b7740238372a8691e0aff944f5965a3
|
7
|
+
data.tar.gz: 59c7fd005d672ad412f9824c4880f2fc9f0141fa56f0bc405d63f9f3a95f05278b54fdceb5e97e93a18116c4e4101dc80db730eecfcd854656e0324fb1074d3d
|
@@ -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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
89
|
-
@opened_at.value = stored_status
|
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
|
@@ -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
|
118
|
-
stats
|
119
|
-
stats
|
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
|
171
|
-
puts "Failure Count: #{stats
|
172
|
-
puts "Success Count: #{stats
|
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
|
175
|
-
puts "Opened At: #{Time.at(stats
|
176
|
-
reset_time = Time.at(stats
|
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
|
184
|
-
puts " Message: #{error_info
|
185
|
-
puts " Time: #{Time.at(error_info
|
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
|
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
module DSL
|
5
|
+
# DSL builder for configuring circuit breakers with a fluent interface
|
6
|
+
class CircuitBuilder
|
7
|
+
attr_reader :config
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@config = {
|
11
|
+
failure_threshold: 5,
|
12
|
+
failure_window: 60.seconds,
|
13
|
+
success_threshold: 1,
|
14
|
+
timeout: nil,
|
15
|
+
reset_timeout: 60.seconds,
|
16
|
+
half_open_calls: 1,
|
17
|
+
exceptions: [StandardError],
|
18
|
+
storage: nil,
|
19
|
+
metrics: nil,
|
20
|
+
fallback: nil,
|
21
|
+
on_open: nil,
|
22
|
+
on_close: nil,
|
23
|
+
on_half_open: nil,
|
24
|
+
on_reject: nil,
|
25
|
+
notifications: [],
|
26
|
+
fiber_safe: BreakerMachines.config.fiber_safe
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def threshold(failures: nil, failure_rate: nil, minimum_calls: nil, within: 60.seconds, successes: nil)
|
31
|
+
if failure_rate
|
32
|
+
# Rate-based threshold
|
33
|
+
validate_failure_rate!(failure_rate)
|
34
|
+
validate_positive_integer!(:minimum_calls, minimum_calls) if minimum_calls
|
35
|
+
|
36
|
+
@config[:failure_rate] = failure_rate
|
37
|
+
@config[:minimum_calls] = minimum_calls || 5
|
38
|
+
@config[:use_rate_threshold] = true
|
39
|
+
elsif failures
|
40
|
+
# Absolute count threshold (existing behavior)
|
41
|
+
validate_positive_integer!(:failures, failures)
|
42
|
+
@config[:failure_threshold] = failures
|
43
|
+
@config[:use_rate_threshold] = false
|
44
|
+
end
|
45
|
+
|
46
|
+
validate_positive_integer!(:within, within.to_i)
|
47
|
+
@config[:failure_window] = within.to_i
|
48
|
+
|
49
|
+
return unless successes
|
50
|
+
|
51
|
+
validate_positive_integer!(:successes, successes)
|
52
|
+
@config[:success_threshold] = successes
|
53
|
+
end
|
54
|
+
|
55
|
+
def reset_after(duration, jitter: nil)
|
56
|
+
validate_positive_integer!(:duration, duration.to_i)
|
57
|
+
@config[:reset_timeout] = duration.to_i
|
58
|
+
|
59
|
+
return unless jitter
|
60
|
+
|
61
|
+
validate_jitter!(jitter)
|
62
|
+
@config[:reset_timeout_jitter] = jitter
|
63
|
+
end
|
64
|
+
|
65
|
+
def timeout(duration)
|
66
|
+
validate_non_negative_integer!(:timeout, duration.to_i)
|
67
|
+
@config[:timeout] = duration.to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
def half_open_requests(count)
|
71
|
+
validate_positive_integer!(:half_open_requests, count)
|
72
|
+
@config[:half_open_calls] = count
|
73
|
+
end
|
74
|
+
|
75
|
+
def storage(backend, **options)
|
76
|
+
@config[:storage] = case backend
|
77
|
+
when :memory
|
78
|
+
Storage::Memory.new(**options)
|
79
|
+
when :bucket_memory
|
80
|
+
Storage::BucketMemory.new(**options)
|
81
|
+
when :cache
|
82
|
+
Storage::Cache.new(**options)
|
83
|
+
when :null
|
84
|
+
Storage::Null.new(**options)
|
85
|
+
when :fallback_chain
|
86
|
+
config = options.is_a?(Proc) ? options.call(timeout: 5) : options
|
87
|
+
Storage::FallbackChain.new(config)
|
88
|
+
when Class
|
89
|
+
backend.new(**options)
|
90
|
+
else
|
91
|
+
backend
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def metrics(recorder = nil, &block)
|
96
|
+
@config[:metrics] = recorder || block
|
97
|
+
end
|
98
|
+
|
99
|
+
def fallback(value = nil, &block)
|
100
|
+
raise ArgumentError, 'Fallback requires either a value or a block' if value.nil? && !block_given?
|
101
|
+
|
102
|
+
fallback_value = block || value
|
103
|
+
|
104
|
+
if @config[:fallback].is_a?(Array)
|
105
|
+
@config[:fallback] << fallback_value
|
106
|
+
elsif @config[:fallback]
|
107
|
+
@config[:fallback] = [@config[:fallback], fallback_value]
|
108
|
+
else
|
109
|
+
@config[:fallback] = fallback_value
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def on_open(&block)
|
114
|
+
@config[:on_open] = block
|
115
|
+
end
|
116
|
+
|
117
|
+
def on_close(&block)
|
118
|
+
@config[:on_close] = block
|
119
|
+
end
|
120
|
+
|
121
|
+
def on_half_open(&block)
|
122
|
+
@config[:on_half_open] = block
|
123
|
+
end
|
124
|
+
|
125
|
+
def on_reject(&block)
|
126
|
+
@config[:on_reject] = block
|
127
|
+
end
|
128
|
+
|
129
|
+
# Configure hedged requests
|
130
|
+
def hedged(&)
|
131
|
+
if block_given?
|
132
|
+
hedged_builder = DSL::HedgedBuilder.new(@config)
|
133
|
+
hedged_builder.instance_eval(&)
|
134
|
+
else
|
135
|
+
@config[:hedged_requests] = true
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Configure multiple backends
|
140
|
+
def backends(*backend_list)
|
141
|
+
@config[:backends] = backend_list.flatten
|
142
|
+
end
|
143
|
+
|
144
|
+
# Configure parallel fallback execution
|
145
|
+
def parallel_fallback(fallback_list)
|
146
|
+
@config[:fallback] = DSL::ParallelFallbackWrapper.new(fallback_list)
|
147
|
+
end
|
148
|
+
|
149
|
+
def notify(service, url = nil, events: %i[open close], **options)
|
150
|
+
notification = {
|
151
|
+
via: service,
|
152
|
+
url: url,
|
153
|
+
events: Array(events),
|
154
|
+
options: options
|
155
|
+
}
|
156
|
+
@config[:notifications] << notification
|
157
|
+
end
|
158
|
+
|
159
|
+
def handle(*exceptions)
|
160
|
+
@config[:exceptions] = exceptions
|
161
|
+
end
|
162
|
+
|
163
|
+
def fiber_safe(enabled = true) # rubocop:disable Style/OptionalBooleanParameter
|
164
|
+
@config[:fiber_safe] = enabled
|
165
|
+
end
|
166
|
+
|
167
|
+
def max_concurrent(limit)
|
168
|
+
validate_positive_integer!(:max_concurrent, limit)
|
169
|
+
@config[:max_concurrent] = limit
|
170
|
+
end
|
171
|
+
|
172
|
+
# Advanced features
|
173
|
+
def parallel_calls(count, timeout: nil)
|
174
|
+
@config[:parallel_calls] = count
|
175
|
+
@config[:parallel_timeout] = timeout
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def validate_positive_integer!(name, value)
|
181
|
+
return if value.is_a?(Integer) && value.positive?
|
182
|
+
|
183
|
+
raise BreakerMachines::ConfigurationError,
|
184
|
+
"#{name} must be a positive integer, got: #{value.inspect}"
|
185
|
+
end
|
186
|
+
|
187
|
+
def validate_non_negative_integer!(name, value)
|
188
|
+
return if value.is_a?(Integer) && value >= 0
|
189
|
+
|
190
|
+
raise BreakerMachines::ConfigurationError,
|
191
|
+
"#{name} must be a non-negative integer, got: #{value.inspect}"
|
192
|
+
end
|
193
|
+
|
194
|
+
def validate_failure_rate!(rate)
|
195
|
+
return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
|
196
|
+
|
197
|
+
raise BreakerMachines::ConfigurationError,
|
198
|
+
"failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
|
199
|
+
end
|
200
|
+
|
201
|
+
def validate_jitter!(jitter)
|
202
|
+
return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
|
203
|
+
|
204
|
+
raise BreakerMachines::ConfigurationError,
|
205
|
+
"jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|