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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e76d6f4335010b14f4ee48ac1366c060bd6b5feaad379896d857856c3dc6a2d
4
- data.tar.gz: d7f48bec133630d584387aaa56df9c3cd3884e8f9771353e8df7e214b8df7431
3
+ metadata.gz: dc152fbf822d0eae49f25c2f8acea2c3268c141d8a62a7aa313168007499e8a5
4
+ data.tar.gz: 5e7d529cde4b41cbb32fca7502249c15465413c4fad31e9a29056fbc99196e09
5
5
  SHA512:
6
- metadata.gz: f98f52400de806bb0df4784d0635560764600ba812b5bc7e99c5eebc8f4ab1d884890d08ca399e458d1f5ca4a1b9192be46562e9b3c66aa311923f0a59f58604
7
- data.tar.gz: c5349911ce027af37b0c41557feb9c0126a1d6d11539650e48f8151855bd7512a859711f0335e9934c18abd879ce28b39adde778bc85d751cdd89728a3c768af
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
- 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
@@ -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