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.
@@ -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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module DSL
5
+ # Builder for hedged request configuration
6
+ class HedgedBuilder
7
+ def initialize(config)
8
+ @config = config
9
+ @config[:hedged_requests] = true
10
+ end
11
+
12
+ def delay(milliseconds)
13
+ @config[:hedging_delay] = milliseconds
14
+ end
15
+
16
+ def max_requests(count)
17
+ @config[:max_hedged_requests] = count
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module DSL
5
+ # Wrapper to indicate parallel execution for fallbacks
6
+ class ParallelFallbackWrapper
7
+ attr_reader :fallbacks
8
+
9
+ def initialize(fallbacks)
10
+ @fallbacks = fallbacks
11
+ end
12
+
13
+ def call(error)
14
+ # This will be handled by the circuit's fallback mechanism
15
+ # to execute fallbacks in parallel
16
+ raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
17
+ end
18
+ end
19
+ end
20
+ end
@@ -43,7 +43,7 @@ module BreakerMachines
43
43
  @circuits ||= {}
44
44
 
45
45
  if block_given?
46
- builder = CircuitBuilder.new
46
+ builder = DSL::CircuitBuilder.new
47
47
  builder.instance_eval(&block)
48
48
  @circuits[name] = builder.config
49
49
  end
@@ -51,6 +51,19 @@ module BreakerMachines
51
51
  @circuits[name]
52
52
  end
53
53
 
54
+ # Define a cascading circuit breaker that can trip dependent circuits
55
+ def cascade_circuit(name, &block)
56
+ @circuits ||= {}
57
+
58
+ if block_given?
59
+ builder = DSL::CascadingCircuitBuilder.new
60
+ builder.instance_eval(&block)
61
+ @circuits[name] = builder.config.merge(circuit_type: :cascading)
62
+ end
63
+
64
+ @circuits[name]
65
+ end
66
+
54
67
  def circuits
55
68
  # Start with parent circuits if available
56
69
  base_circuits = if superclass.respond_to?(:circuits)
@@ -72,7 +85,7 @@ module BreakerMachines
72
85
  @circuit_templates ||= {}
73
86
 
74
87
  if block_given?
75
- builder = CircuitBuilder.new
88
+ builder = DSL::CircuitBuilder.new
76
89
  builder.instance_eval(&block)
77
90
  @circuit_templates[name] = builder.config
78
91
  end
@@ -156,7 +169,16 @@ module BreakerMachines
156
169
  def circuit(name)
157
170
  self.class.circuits[name] ||= {}
158
171
  @circuit_instances ||= {}
159
- @circuit_instances[name] ||= Circuit.new(name, self.class.circuits[name].merge(owner: self))
172
+
173
+ config = self.class.circuits[name].merge(owner: self)
174
+ circuit_type = config.delete(:circuit_type)
175
+
176
+ @circuit_instances[name] ||= case circuit_type
177
+ when :cascading
178
+ CascadingCircuit.new(name, config)
179
+ else
180
+ Circuit.new(name, config)
181
+ end
160
182
  end
161
183
 
162
184
  # Create a dynamic circuit breaker with inline configuration
@@ -173,7 +195,7 @@ module BreakerMachines
173
195
 
174
196
  # Apply additional configuration if block provided
175
197
  if config_block
176
- builder = CircuitBuilder.new
198
+ builder = DSL::CircuitBuilder.new
177
199
  builder.instance_variable_set(:@config, base_config.deep_dup)
178
200
  builder.instance_eval(&config_block)
179
201
  base_config = builder.config
@@ -257,237 +279,5 @@ module BreakerMachines
257
279
  def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
258
280
  BreakerMachines.registry.cleanup_stale_dynamic_circuits(max_age_seconds)
259
281
  end
260
-
261
- # DSL builder for configuring circuit breakers with a fluent interface
262
- class CircuitBuilder
263
- attr_reader :config
264
-
265
- def initialize
266
- @config = {
267
- failure_threshold: 5,
268
- failure_window: 60.seconds,
269
- success_threshold: 1,
270
- timeout: nil,
271
- reset_timeout: 60.seconds,
272
- half_open_calls: 1,
273
- exceptions: [StandardError],
274
- storage: nil,
275
- metrics: nil,
276
- fallback: nil,
277
- on_open: nil,
278
- on_close: nil,
279
- on_half_open: nil,
280
- on_reject: nil,
281
- notifications: [],
282
- fiber_safe: BreakerMachines.config.fiber_safe
283
- }
284
- end
285
-
286
- def threshold(failures: nil, failure_rate: nil, minimum_calls: nil, within: 60.seconds, successes: nil)
287
- if failure_rate
288
- # Rate-based threshold
289
- validate_failure_rate!(failure_rate)
290
- validate_positive_integer!(:minimum_calls, minimum_calls) if minimum_calls
291
-
292
- @config[:failure_rate] = failure_rate
293
- @config[:minimum_calls] = minimum_calls || 5
294
- @config[:use_rate_threshold] = true
295
- elsif failures
296
- # Absolute count threshold (existing behavior)
297
- validate_positive_integer!(:failures, failures)
298
- @config[:failure_threshold] = failures
299
- @config[:use_rate_threshold] = false
300
- end
301
-
302
- validate_positive_integer!(:within, within.to_i)
303
- @config[:failure_window] = within.to_i
304
-
305
- return unless successes
306
-
307
- validate_positive_integer!(:successes, successes)
308
- @config[:success_threshold] = successes
309
- end
310
-
311
- def reset_after(duration, jitter: nil)
312
- validate_positive_integer!(:duration, duration.to_i)
313
- @config[:reset_timeout] = duration.to_i
314
-
315
- return unless jitter
316
-
317
- validate_jitter!(jitter)
318
- @config[:reset_timeout_jitter] = jitter
319
- end
320
-
321
- def timeout(duration)
322
- validate_non_negative_integer!(:timeout, duration.to_i)
323
- @config[:timeout] = duration.to_i
324
- end
325
-
326
- def half_open_requests(count)
327
- validate_positive_integer!(:half_open_requests, count)
328
- @config[:half_open_calls] = count
329
- end
330
-
331
- def storage(backend, **)
332
- @config[:storage] = case backend
333
- when :memory
334
- Storage::Memory.new(**)
335
- when :bucket_memory
336
- Storage::BucketMemory.new(**)
337
- when :cache
338
- Storage::Cache.new(**)
339
- when :redis
340
- Storage::Redis.new(**)
341
- when Class
342
- backend.new(**)
343
- else
344
- backend
345
- end
346
- end
347
-
348
- def metrics(recorder = nil, &block)
349
- @config[:metrics] = recorder || block
350
- end
351
-
352
- def fallback(value = nil, &block)
353
- raise ArgumentError, 'Fallback requires either a value or a block' if value.nil? && !block_given?
354
-
355
- fallback_value = block || value
356
-
357
- if @config[:fallback].is_a?(Array)
358
- @config[:fallback] << fallback_value
359
- elsif @config[:fallback]
360
- @config[:fallback] = [@config[:fallback], fallback_value]
361
- else
362
- @config[:fallback] = fallback_value
363
- end
364
- end
365
-
366
- def on_open(&block)
367
- @config[:on_open] = block
368
- end
369
-
370
- def on_close(&block)
371
- @config[:on_close] = block
372
- end
373
-
374
- def on_half_open(&block)
375
- @config[:on_half_open] = block
376
- end
377
-
378
- def on_reject(&block)
379
- @config[:on_reject] = block
380
- end
381
-
382
- # Configure hedged requests
383
- def hedged(&)
384
- if block_given?
385
- hedged_builder = HedgedBuilder.new(@config)
386
- hedged_builder.instance_eval(&)
387
- else
388
- @config[:hedged_requests] = true
389
- end
390
- end
391
-
392
- # Configure multiple backends
393
- def backends(*backend_list)
394
- @config[:backends] = backend_list.flatten
395
- end
396
-
397
- # Configure parallel fallback execution
398
- def parallel_fallback(fallback_list)
399
- @config[:fallback] = ParallelFallbackWrapper.new(fallback_list)
400
- end
401
-
402
- def notify(service, url = nil, events: %i[open close], **options)
403
- notification = {
404
- via: service,
405
- url: url,
406
- events: Array(events),
407
- options: options
408
- }
409
- @config[:notifications] << notification
410
- end
411
-
412
- def handle(*exceptions)
413
- @config[:exceptions] = exceptions
414
- end
415
-
416
- def fiber_safe(enabled = true) # rubocop:disable Style/OptionalBooleanParameter
417
- @config[:fiber_safe] = enabled
418
- end
419
-
420
- def max_concurrent(limit)
421
- validate_positive_integer!(:max_concurrent, limit)
422
- @config[:max_concurrent] = limit
423
- end
424
-
425
- # Advanced features
426
- def parallel_calls(count, timeout: nil)
427
- @config[:parallel_calls] = count
428
- @config[:parallel_timeout] = timeout
429
- end
430
-
431
- private
432
-
433
- def validate_positive_integer!(name, value)
434
- return if value.is_a?(Integer) && value.positive?
435
-
436
- raise BreakerMachines::ConfigurationError,
437
- "#{name} must be a positive integer, got: #{value.inspect}"
438
- end
439
-
440
- def validate_non_negative_integer!(name, value)
441
- return if value.is_a?(Integer) && value >= 0
442
-
443
- raise BreakerMachines::ConfigurationError,
444
- "#{name} must be a non-negative integer, got: #{value.inspect}"
445
- end
446
-
447
- def validate_failure_rate!(rate)
448
- return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
449
-
450
- raise BreakerMachines::ConfigurationError,
451
- "failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
452
- end
453
-
454
- def validate_jitter!(jitter)
455
- return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
456
-
457
- raise BreakerMachines::ConfigurationError,
458
- "jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
459
- end
460
- end
461
-
462
- # Builder for hedged request configuration
463
- class HedgedBuilder
464
- def initialize(config)
465
- @config = config
466
- @config[:hedged_requests] = true
467
- end
468
-
469
- def delay(milliseconds)
470
- @config[:hedging_delay] = milliseconds
471
- end
472
-
473
- def max_requests(count)
474
- @config[:max_hedged_requests] = count
475
- end
476
- end
477
-
478
- # Wrapper to indicate parallel execution for fallbacks
479
- class ParallelFallbackWrapper
480
- attr_reader :fallbacks
481
-
482
- def initialize(fallbacks)
483
- @fallbacks = fallbacks
484
- end
485
-
486
- def call(error)
487
- # This will be handled by the circuit's fallback mechanism
488
- # to execute fallbacks in parallel
489
- raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
490
- end
491
- end
492
282
  end
493
283
  end
@@ -28,6 +28,16 @@ module BreakerMachines
28
28
  class ConfigurationError < Error; end
29
29
  class StorageError < Error; end
30
30
 
31
+ # Raised when storage backend operation times out
32
+ class StorageTimeoutError < StorageError
33
+ attr_reader :timeout_ms
34
+
35
+ def initialize(message, timeout_ms = nil)
36
+ @timeout_ms = timeout_ms
37
+ super(message)
38
+ end
39
+ end
40
+
31
41
  # Raised when circuit rejects call due to bulkhead limit
32
42
  class CircuitBulkheadError < Error
33
43
  attr_reader :circuit_name, :max_concurrent
@@ -102,13 +102,13 @@ module BreakerMachines
102
102
 
103
103
  {
104
104
  summary: stats_summary,
105
- circuits: circuits.map(&:stats),
105
+ circuits: circuits.map { |c| c.stats.to_h },
106
106
  health: {
107
107
  open_count: circuits.count(&:open?),
108
108
  closed_count: circuits.count(&:closed?),
109
109
  half_open_count: circuits.count(&:half_open?),
110
- total_failures: circuits.sum { |c| c.stats[:failure_count] },
111
- total_successes: circuits.sum { |c| c.stats[:success_count] }
110
+ total_failures: circuits.sum { |c| c.stats.failure_count },
111
+ total_successes: circuits.sum { |c| c.stats.success_count }
112
112
  }
113
113
  }
114
114
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module Storage
5
+ # Manages the health state of a single storage backend using a state machine.
6
+ class BackendState
7
+ attr_reader :name, :failure_count, :last_failure_at
8
+ attr_accessor :health
9
+
10
+ def initialize(name, threshold:, timeout:)
11
+ @name = name
12
+ @threshold = threshold
13
+ @timeout = timeout
14
+ @failure_count = 0
15
+ @last_failure_at = nil
16
+ @health = :healthy
17
+ end
18
+
19
+ state_machine :health, initial: :healthy do
20
+ event :trip do
21
+ transition healthy: :unhealthy, if: :threshold_reached?
22
+ end
23
+
24
+ event :recover do
25
+ transition unhealthy: :healthy
26
+ end
27
+
28
+ event :reset do
29
+ transition all => :healthy
30
+ end
31
+
32
+ before_transition to: :unhealthy do |backend, _transition|
33
+ backend.instance_variable_set(:@unhealthy_until,
34
+ BreakerMachines.monotonic_time + backend.instance_variable_get(:@timeout))
35
+ end
36
+
37
+ after_transition to: :healthy do |backend, _transition|
38
+ backend.instance_variable_set(:@failure_count, 0)
39
+ backend.instance_variable_set(:@last_failure_at, nil)
40
+ backend.instance_variable_set(:@unhealthy_until, nil)
41
+ end
42
+ end
43
+
44
+ def record_failure
45
+ @failure_count += 1
46
+ @last_failure_at = BreakerMachines.monotonic_time
47
+ trip
48
+ end
49
+
50
+ def threshold_reached?
51
+ @failure_count >= @threshold
52
+ end
53
+
54
+ def unhealthy_due_to_timeout?
55
+ return false unless unhealthy?
56
+
57
+ unhealthy_until = instance_variable_get(:@unhealthy_until)
58
+ return false unless unhealthy_until
59
+
60
+ if BreakerMachines.monotonic_time > unhealthy_until
61
+ recover
62
+ false
63
+ else
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -42,6 +42,11 @@ module BreakerMachines
42
42
  def clear_all
43
43
  raise NotImplementedError
44
44
  end
45
+
46
+ # Timeout handling - each backend must implement its own timeout strategy
47
+ def with_timeout(timeout_ms)
48
+ raise NotImplementedError, "#{self.class} must implement #with_timeout to handle #{timeout_ms}ms timeouts"
49
+ end
45
50
  end
46
51
  end
47
52
  end
@@ -7,6 +7,10 @@ module BreakerMachines
7
7
  module Storage
8
8
  # Efficient bucket-based memory storage implementation
9
9
  # Uses fixed-size circular buffers for constant-time event counting
10
+ #
11
+ # WARNING: This storage backend is NOT compatible with DRb (distributed Ruby)
12
+ # environments as memory is not shared between processes. Use Cache backend
13
+ # with an external cache store (Redis, Memcached) for distributed setups.
10
14
  class BucketMemory < Base
11
15
  BUCKET_SIZE = 1 # 1 second per bucket
12
16
 
@@ -23,10 +27,10 @@ module BreakerMachines
23
27
  circuit_data = @circuits[circuit_name]
24
28
  return nil unless circuit_data
25
29
 
26
- {
30
+ BreakerMachines::Status.new(
27
31
  status: circuit_data[:status],
28
32
  opened_at: circuit_data[:opened_at]
29
- }
33
+ )
30
34
  end
31
35
 
32
36
  def set_status(circuit_name, status, opened_at = nil)
@@ -156,7 +160,13 @@ module BreakerMachines
156
160
  end
157
161
 
158
162
  def monotonic_time
159
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
163
+ BreakerMachines.monotonic_time
164
+ end
165
+
166
+ def with_timeout(_timeout_ms)
167
+ # BucketMemory operations should be instant, but we'll still respect the timeout
168
+ # This is more for consistency and to catch any potential deadlocks
169
+ yield
160
170
  end
161
171
  end
162
172
  end