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 +4 -4
- data/README.md +75 -58
- 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 -236
- data/lib/breaker_machines/errors.rb +10 -0
- data/lib/breaker_machines/registry.rb +3 -3
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/base.rb +5 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +13 -3
- data/lib/breaker_machines/storage/cache.rb +10 -3
- data/lib/breaker_machines/storage/fallback_chain.rb +294 -0
- data/lib/breaker_machines/storage/memory.rb +13 -3
- data/lib/breaker_machines/storage/null.rb +9 -0
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +12 -0
- data/sig/README.md +3 -3
- metadata +14 -6
@@ -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
|
data/lib/breaker_machines/dsl.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
111
|
-
total_successes: circuits.sum { |c| c.stats
|
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
|
-
|
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
|