circuitbox 2.0.0.pre4 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb89d6ef46d73dfa35e7c43181112309aa524979ec1285688cc8cebf8d781bd4
4
- data.tar.gz: 735c23115ea5127923c05b84d5b6cfe64193521bcd751639cc6ceab32c6b97ce
3
+ metadata.gz: 206d564d542dc903001281052403166a100732fc9b2b0f1f0999601e4543893b
4
+ data.tar.gz: '0095c6111ab38a311cb48ae8df82906185cef5b2b3ee8e761f08c9bacfb95782'
5
5
  SHA512:
6
- metadata.gz: a29d10725e8a1a36eec12985681cd66b7c2b3303cb996bfe72226674230d7c8ef38b02bb5731665edd49c7922b2a9f0072434a2ab33da7f64713875415071420
7
- data.tar.gz: 9a165096e2b0e5fe484c433284a3c0b6658f4b880552602cb57b22fcf3ea540302b3d3a0910b31b8f3e1938259ca6394faefd3bffffc6c40bf3104d8d02564ec
6
+ metadata.gz: 314fe848d0dfbcc1aab3b71f469349b950b3dd117df76fcc88d2e37da574ce03000a9f6b62f712d7e4d1b069d5428d12d75d29bb91410ed4c0a83d8a6bfd41ae
7
+ data.tar.gz: 613f95add06ef602442bcde07b62f12d2fdee60c5713fcb72b71d09ddcbe8085340ffdcbd9ad83161cf31b55536c2e9ec5cce0beaddfac3737fd7a39c62aec3f
data/README.md CHANGED
@@ -2,10 +2,16 @@
2
2
 
3
3
  ![Tests](https://github.com/yammer/circuitbox/workflows/Tests/badge.svg) [![Gem Version](https://badge.fury.io/rb/circuitbox.svg)](https://badge.fury.io/rb/circuitbox)
4
4
 
5
- Circuitbox is a Ruby circuit breaker gem. It protects your application from failures of its service dependencies. It wraps calls to external services and monitors for failures in one minute intervals. Once more than 10 requests have been made with a 50% failure rate, Circuitbox stops sending requests to that failing service for one minute. This helps your application gracefully degrade.
5
+ Circuitbox is a Ruby circuit breaker gem.
6
+ It protects your application from failures of its service dependencies.
7
+ It wraps calls to external services and monitors for failures in one minute intervals.
8
+ Using a circuit's defaults once more than 5 requests have been made with a 50% failure rate, Circuitbox stops sending requests to that failing service for 90 seconds.
9
+ This helps your application gracefully degrade.
10
+
6
11
  Resources about the circuit breaker pattern:
7
12
  * [http://martinfowler.com/bliki/CircuitBreaker.html](http://martinfowler.com/bliki/CircuitBreaker.html)
8
- * [https://github.com/Netflix/Hystrix/wiki/How-it-Works#CircuitBreaker](https://github.com/Netflix/Hystrix/wiki/How-it-Works#CircuitBreaker)
13
+
14
+ *Upgrading to 2.x? See [2.0 upgrade](docs/2.0-upgrade.md)*
9
15
 
10
16
  ## Usage
11
17
 
@@ -25,7 +31,7 @@ class ExampleServiceClient
25
31
  end
26
32
 
27
33
  def http_get
28
- circuit.run(circuitbox_exceptions: false) do
34
+ circuit.run(exception: false) do
29
35
  Zephyr.new("http://example.com").get(200, 1000, "/api/messages")
30
36
  end
31
37
  end
@@ -43,19 +49,22 @@ Using the `run` method will throw an exception when the circuit is open or the u
43
49
  ```
44
50
 
45
51
  ## Global Configuration
46
- Circuitbox has defaults for circuit_store, notifier, and logger.
47
- This can be configured through ```Circuitbox.configure```.
48
- The circuit cache used by ```Circuitbox.circuit``` will be cleared after running ```Circuitbox.configure```.
49
- This means when accessing the circuit through ```Circuitbox.circuit``` any custom configuration options should always be given.
50
52
 
51
- Any circuit created manually through ```Circuitbox::CircuitBreaker``` before updating the configuration
52
- will need to be recreated to pick up the new defaults.
53
+ Circuitbox defaults can be configured through ```Circuitbox.configure```.
54
+ There are two defaults that can be configured:
55
+ * `default_circuit_store` - Defaults to a `Circuitbox::MemoryStore`. This can be changed to a compatible Moneta store.
56
+ * `default_notifier` - Defaults to `Circuitbox::Notifier::ActiveSupport` if `ActiveSupport::Notifications` is defined, otherwise defaults to `Circuitbox::Notifier::Null`
57
+
58
+ After configuring circuitbox through `Circuitbox.configure`, the internal circuit cache of `Circuitbox.circuit` is cleared.
59
+
60
+ Any circuit created manually through ```Circuitbox::CircuitBreaker``` before updating the configuration will need to be recreated to pick up the new defaults.
61
+
62
+ The following is an example Circuitbox configuration:
53
63
 
54
64
  ```ruby
55
65
  Circuitbox.configure do |config|
56
66
  config.default_circuit_store = Circuitbox::MemoryStore.new
57
67
  config.default_notifier = Circuitbox::Notifier::Null.new
58
- config.default_logger = Rails.logger
59
68
  end
60
69
  ```
61
70
 
@@ -81,17 +90,12 @@ class ExampleServiceClient
81
90
  # the store you want to use to save the circuit state so it can be
82
91
  # tracked, this needs to be Moneta compatible, and support increment
83
92
  # this overrides what is set in the global configuration
84
- cache: Circuitbox::MemoryStore.new,
93
+ circuit_store: Circuitbox::MemoryStore.new,
85
94
 
86
95
  # exceeding this rate will open the circuit (checked on failures)
87
96
  error_threshold: 50,
88
97
 
89
- # Logger to use
90
- # This overrides what is set in the global configuration
91
- logger: Logger.new(STDOUT),
92
-
93
98
  # Customized notifier
94
- # overrides the default
95
99
  # this overrides what is set in the global configuration
96
100
  notifier: Notifier.new
97
101
  })
@@ -108,16 +112,17 @@ Circuitbox.circuit(:yammer, {
108
112
  })
109
113
  ```
110
114
 
111
- ## Circuit Store (:cache)
115
+ ## Circuit Store
112
116
 
113
117
  Holds all the relevant data to trip the circuit if a given number of requests
114
118
  fail in a specified period of time. Circuitbox also supports
115
- [Moneta](https://github.com/minad/moneta). As moneta is not a dependency of circuitbox
119
+ [Moneta](https://github.com/moneta-rb/moneta). As moneta is not a dependency of circuitbox
116
120
  it needs to be loaded prior to use. There are a lot of moneta stores to choose from but
117
121
  some pre-requisits need to be satisfied first:
118
122
 
119
123
  - Needs to support increment, this is true for most but not all available stores.
120
124
  - Needs to support expiry.
125
+ - Needs to support bulk read.
121
126
  - Needs to support concurrent access if you share them. For example sharing a
122
127
  KyotoCabinet store across process fails because the store is single writer
123
128
  multiple readers, and all circuits sharing the store need to be able to write.
@@ -125,60 +130,14 @@ some pre-requisits need to be satisfied first:
125
130
 
126
131
  ## Notifications
127
132
 
128
- circuitbox use ActiveSupport Notifications.
129
-
130
- Usage example:
131
-
132
- **Log on circuit open/close:**
133
-
134
- ```ruby
135
- class CircuitOpenException < StandardError ; end
136
-
137
- ActiveSupport::Notifications.subscribe('circuit_open') do |name, start, finish, id, payload|
138
- circuit_name = payload[:circuit]
139
- Rails.logger.warn("Open circuit for: #{circuit_name}")
140
- end
141
- ActiveSupport::Notifications.subscribe('circuit_close') do |name, start, finish, id, payload|
142
- circuit_name = payload[:circuit]
143
- Rails.logger.info("Close circuit for: #{circuit_name}")
144
- end
145
- ```
146
-
147
- **generate metrics:**
148
-
149
- ```ruby
150
- $statsd = Statsd.new 'localhost', 9125
151
-
152
- ActiveSupport::Notifications.subscribe('circuit_gauge') do |name, start, finish, id, payload|
153
- circuit_name = payload[:circuit]
154
- gauge = payload[:gauge]
155
- value = payload[:value]
156
- metrics_key = "circuitbox.circuit.#{circuit_name}.#{gauge}"
157
-
158
- $statsd.gauge(metrics_key, value)
159
- end
160
- ```
161
-
162
- `payload[:gauge]` can be:
163
-
164
- - `runtime` # runtime will only be notified when circuit is closed and block is successfully executed.
165
-
166
- **warnings:**
167
- in case of misconfiguration, circuitbox will fire a circuitbox_warning
168
- notification.
169
-
170
- ```ruby
171
- ActiveSupport::Notifications.subscribe('circuit_warning') do |name, start, finish, id, payload|
172
- circuit_name = payload[:circuit]
173
- warning = payload[:message]
174
- Rails.logger.warning("#{circuit_name} - #{warning}")
175
- end
176
-
177
- ```
133
+ See [Circuit Notifications](docs/circuit_notifications.md)
178
134
 
179
135
  ## Faraday
180
136
 
181
- Circuitbox ships with [Faraday HTTP client](https://github.com/lostisland/faraday) middleware.
137
+ Circuitbox ships with a [Faraday HTTP client](https://github.com/lostisland/faraday) middleware.
138
+ The versions of faraday the middleware has been tested against is `>= 0.17` through `~> 2.0`.
139
+ The middleware does not support parallel requests through a connections `in_parallel` method.
140
+
182
141
 
183
142
  ```ruby
184
143
  require 'faraday'
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'circuit_breaker/logger_messages'
3
+ require_relative 'time_helper/monotonic'
4
+ require_relative 'time_helper/real'
4
5
 
5
6
  class Circuitbox
6
7
  class CircuitBreaker
7
- include LoggerMessages
8
-
9
8
  attr_reader :service, :circuit_options, :exceptions,
10
- :logger, :circuit_store, :notifier, :time_class
9
+ :circuit_store, :notifier, :time_class
11
10
 
12
11
  DEFAULTS = {
13
12
  sleep_window: 90,
@@ -16,20 +15,23 @@ class Circuitbox
16
15
  time_window: 60
17
16
  }.freeze
18
17
 
18
+ # Initialize a CircuitBreaker
19
19
  #
20
- # Configuration options
21
- #
22
- # `sleep_window` - seconds to sleep the circuit
23
- # `volume_threshold` - number of requests before error rate calculation occurs
24
- # `error_threshold` - percentage of failed requests needed to trip circuit
25
- # `exceptions` - exceptions that count as failures
26
- # `time_window` - interval of time used to calculate error_rate (in seconds) - default is 60s
27
- # `logger` - Logger to use - defaults to Rails.logger if defined, otherwise STDOUT
20
+ # @param service [String, Symbol] Name of the circuit for notifications and metrics store
21
+ # @param options [Hash] Options to create the circuit with
22
+ # @option options [Integer] :time_window (60) Interval of time, in seconds, used to calculate the error_rate
23
+ # @option options [Integer, Proc] :sleep_window (90) Seconds for the circuit to stay open when tripped
24
+ # @option options [Integer, Proc] :volume_threshold (5) Number of requests before error rate is first calculated
25
+ # @option options [Integer, Proc] :error_threshold (50) Percentage of failed requests needed to trip the circuit
26
+ # @option options [Array] :exceptions The exceptions that should be monitored and counted as failures
27
+ # @option options [Circuitbox::MemoryStore, Moneta] :circuit_store (Circuitbox.default_circuit_store) Class to store circuit open/close statistics
28
+ # @option options [Object] :notifier (Circuitbox.default_notifier) Class notifications are sent to
28
29
  #
30
+ # @raise [ArgumentError] If the exceptions option is not an Array
29
31
  def initialize(service, options = {})
30
32
  @service = service.to_s
31
33
  @circuit_options = DEFAULTS.merge(options)
32
- @circuit_store = options.fetch(:cache) { Circuitbox.default_circuit_store }
34
+ @circuit_store = options.fetch(:circuit_store) { Circuitbox.default_circuit_store }
33
35
  @notifier = options.fetch(:notifier) { Circuitbox.default_notifier }
34
36
 
35
37
  if @circuit_options[:timeout_seconds]
@@ -37,47 +39,73 @@ class Circuitbox
37
39
  'Check the upgrade guide at https://github.com/yammer/circuitbox')
38
40
  end
39
41
 
42
+ if @circuit_options[:cache]
43
+ warn('cache was changed to circuit_store in circuitbox 2.0. '\
44
+ 'Check the upgrade guide at https://github.com/yammer/circuitbox')
45
+ end
46
+
40
47
  @exceptions = options.fetch(:exceptions)
41
- raise ArgumentError.new('exceptions need to be an array') unless @exceptions.is_a?(Array)
48
+ raise ArgumentError.new('exceptions must be an array') unless @exceptions.is_a?(Array)
49
+
50
+ @time_class = options.fetch(:time_class) { default_time_klass }
42
51
 
43
- @logger = options.fetch(:logger) { Circuitbox.default_logger }
44
- @time_class = options.fetch(:time_class) { Time }
45
52
  @state_change_mutex = Mutex.new
53
+ @open_storage_key = "circuits:#{@service}:open"
54
+ @half_open_storage_key = "circuits:#{@service}:half_open"
46
55
  check_sleep_window
47
56
  end
48
57
 
49
58
  def option_value(name)
50
- value = circuit_options[name]
59
+ value = @circuit_options[name]
51
60
  value.is_a?(Proc) ? value.call : value
52
61
  end
53
62
 
54
- def run(circuitbox_exceptions: true, &block)
63
+ # Run the circuit with the given block.
64
+ # If the circuit is closed or half_open the block will run.
65
+ # If the circuit is open the block will not be run.
66
+ #
67
+ # @param exception [Boolean] If exceptions should be raised when the circuit is open
68
+ # or when a watched exception is raised from the block
69
+ # @yield Block to run if circuit is not open
70
+ #
71
+ # @raise [Circuitbox::OpenCircuitError] If the circuit is open and exception is true
72
+ # @raise [Circuitbox::ServiceFailureError] If a tracked exception is raised from the block and exception is true
73
+ #
74
+ # @return [Object] The result from the block
75
+ # @return [Nil] If the circuit is open and exception is false
76
+ # In cases where an exception that circuitbox is watching is raised from either a notifier
77
+ # or from a custom circuit store nil can be returned even though the block ran successfully
78
+ def run(exception: true, &block)
55
79
  if open?
56
80
  skipped!
57
- raise Circuitbox::OpenCircuitError.new(service) if circuitbox_exceptions
81
+ raise Circuitbox::OpenCircuitError.new(@service) if exception
58
82
  else
59
- logger.debug(circuit_running_message)
60
-
61
83
  begin
62
- response = Timer.measure(service, notifier, 'runtime', &block)
84
+ response = @notifier.notify_run(@service, &block)
63
85
 
64
86
  success!
65
- rescue *exceptions => e
87
+ rescue *@exceptions => e
66
88
  # Other stores could raise an exception that circuitbox is asked to watch.
67
- # setting to nil keeps the same behavior as the previous defination of run.
89
+ # setting to nil keeps the same behavior as the previous definition of run.
68
90
  response = nil
69
91
  failure!
70
- raise Circuitbox::ServiceFailureError.new(service, e) if circuitbox_exceptions
92
+ raise Circuitbox::ServiceFailureError.new(@service, e) if exception
71
93
  end
72
94
  end
73
95
 
74
96
  response
75
97
  end
76
98
 
99
+ # Check if the circuit is open
100
+ #
101
+ # @return [Boolean] True if circuit is open, False if closed
77
102
  def open?
78
- circuit_store.key?(open_storage_key)
103
+ @circuit_store.key?(@open_storage_key)
79
104
  end
80
105
 
106
+ # Calculates the current error rate of the circuit
107
+ #
108
+ # @return [Float] Error Rate
81
109
  def error_rate(failures = failure_count, success = success_count)
82
110
  all_count = failures + success
83
111
  return 0.0 unless all_count.positive?
@@ -85,34 +113,52 @@ class Circuitbox
85
113
  (failures / all_count.to_f) * 100
86
114
  end
87
115
 
116
+ # Number of Failures the circuit has encountered in the current time window
117
+ #
118
+ # @return [Integer] Number of failures
88
119
  def failure_count
89
- circuit_store.load(stat_storage_key('failure'), raw: true).to_i
120
+ @circuit_store.load(stat_storage_key('failure'), raw: true).to_i
90
121
  end
91
122
 
123
+ # Number of successes the circuit has encountered in the current time window
124
+ #
125
+ # @return [Integer] Number of successes
92
126
  def success_count
93
- circuit_store.load(stat_storage_key('success'), raw: true).to_i
127
+ @circuit_store.load(stat_storage_key('success'), raw: true).to_i
94
128
  end
95
129
 
130
+ # If the circuit is open the key indicating that the circuit is open
131
+ # On the next call to run the circuit would run as if it were in the half open state
132
+ #
133
+ # This does not reset any of the circuit success/failure state so future failures
134
+ # in the same time window may cause the circuit to open sooner
96
135
  def try_close_next_time
97
- circuit_store.delete(open_storage_key)
136
+ @circuit_store.delete(@open_storage_key)
98
137
  end
99
138
 
100
139
  private
101
140
 
102
141
  def should_open?
103
- failures = failure_count
104
- successes = success_count
105
- rate = error_rate(failures, successes)
106
-
107
- passed_volume_threshold?(failures, successes) && passed_rate_threshold?(rate)
142
+ aligned_time = align_time_to_window
143
+
144
+ failures, successes = @circuit_store.values_at(stat_storage_key('failure', aligned_time),
145
+ stat_storage_key('success', aligned_time),
146
+ raw: true)
147
+ # Calling to_i is only needed for moneta stores which can return a string representation of an integer.
148
+ # While readability could increase by adding .map(&:to_i) to the end of the values_at call it's also slightly
149
+ # less performant when we only have two values to convert.
150
+ failures = failures.to_i
151
+ successes = successes.to_i
152
+
153
+ passed_volume_threshold?(failures, successes) && passed_rate_threshold?(failures, successes)
108
154
  end
109
155
 
110
156
  def passed_volume_threshold?(failures, successes)
111
157
  failures + successes >= option_value(:volume_threshold)
112
158
  end
113
159
 
114
- def passed_rate_threshold?(rate)
115
- rate >= option_value(:error_threshold)
160
+ def passed_rate_threshold?(failures, successes)
161
+ error_rate(failures, successes) >= option_value(:error_threshold)
116
162
  end
117
163
 
118
164
  def half_open_failure
@@ -122,7 +168,7 @@ class Circuitbox
122
168
  trip
123
169
  end
124
170
 
125
- # Running event and logger outside of the synchronize block to allow other threads
171
+ # Running event outside of the synchronize block to allow other threads
126
172
  # that may be waiting to become unblocked
127
173
  notify_opened
128
174
  end
@@ -134,19 +180,18 @@ class Circuitbox
134
180
  trip
135
181
  end
136
182
 
137
- # Running event and logger outside of the synchronize block to allow other threads
183
+ # Running event outside of the synchronize block to allow other threads
138
184
  # that may be waiting to become unblocked
139
185
  notify_opened
140
186
  end
141
187
 
142
188
  def notify_opened
143
189
  notify_event('open')
144
- logger.debug(circuit_opened_message)
145
190
  end
146
191
 
147
192
  def trip
148
- circuit_store.store(open_storage_key, true, expires: option_value(:sleep_window))
149
- circuit_store.store(half_open_storage_key, true)
193
+ @circuit_store.store(@open_storage_key, true, expires: option_value(:sleep_window))
194
+ @circuit_store.store(@half_open_storage_key, true)
150
195
  end
151
196
 
152
197
  def close!
@@ -154,29 +199,26 @@ class Circuitbox
154
199
  # If the circuit is not open, the half_open key will be deleted from the store
155
200
  # if half_open exists the deleted value is returned and allows us to continue
156
201
  # if half_open doesn't exist nil is returned, causing us to return early
157
- return unless !open? && circuit_store.delete(half_open_storage_key)
202
+ return unless !open? && @circuit_store.delete(@half_open_storage_key)
158
203
  end
159
204
 
160
205
  # Running event outside of the synchronize block to allow other threads
161
206
  # that may be waiting to become unblocked
162
207
  notify_event('close')
163
- logger.debug(circuit_closed_message)
164
208
  end
165
209
 
166
210
  def half_open?
167
- circuit_store.key?(half_open_storage_key)
211
+ @circuit_store.key?(@half_open_storage_key)
168
212
  end
169
213
 
170
214
  def success!
171
215
  increment_and_notify_event('success')
172
- logger.debug(circuit_success_message)
173
216
 
174
217
  close! if half_open?
175
218
  end
176
219
 
177
220
  def failure!
178
221
  increment_and_notify_event('failure')
179
- logger.debug(circuit_failure_message)
180
222
 
181
223
  if half_open?
182
224
  half_open_failure
@@ -187,20 +229,31 @@ class Circuitbox
187
229
 
188
230
  def skipped!
189
231
  notify_event('skipped')
190
- logger.debug(circuit_skipped_message)
191
232
  end
192
233
 
193
234
  # Send event notification to notifier
194
235
  def notify_event(event)
195
- notifier.notify(service, event)
236
+ @notifier.notify(@service, event)
196
237
  end
197
238
 
198
239
  # Increment stat store and send notification
199
240
  def increment_and_notify_event(event)
200
- circuit_store.increment(stat_storage_key(event), 1, expires: (option_value(:time_window) * 2))
241
+ time_window = option_value(:time_window)
242
+ aligned_time = align_time_to_window(time_window)
243
+ @circuit_store.increment(stat_storage_key(event, aligned_time), 1, expires: time_window)
201
244
  notify_event(event)
202
245
  end
203
246
 
247
+ def stat_storage_key(event, aligned_time = align_time_to_window)
248
+ "circuits:#{@service}:stats:#{aligned_time}:#{event}"
249
+ end
250
+
251
+ # return time representation in seconds
252
+ def align_time_to_window(window = option_value(:time_window))
253
+ time = @time_class.current_second
254
+ time - (time % window) # remove rest of integer division
255
+ end
256
+
204
257
  def check_sleep_window
205
258
  sleep_window = option_value(:sleep_window)
206
259
  time_window = option_value(:time_window)
@@ -208,27 +261,16 @@ class Circuitbox
208
261
 
209
262
  warning_message = "sleep_window: #{sleep_window} is shorter than time_window: #{time_window}, "\
210
263
  "the error_rate would not be reset after a sleep."
211
- notifier.notify_warning(service, warning_message)
212
- warn("Circuit: #{service}, Warning: #{warning_message}")
213
- end
214
-
215
- def stat_storage_key(event)
216
- "circuits:#{service}:stats:#{align_time_to_window}:#{event}"
264
+ @notifier.notify_warning(@service, warning_message)
265
+ warn("Circuit: #{@service}, Warning: #{warning_message}")
217
266
  end
218
267
 
219
- # return time representation in seconds
220
- def align_time_to_window
221
- time = time_class.now.to_i
222
- time_window = option_value(:time_window)
223
- time - (time % time_window) # remove rest of integer division
224
- end
225
-
226
- def open_storage_key
227
- @open_storage_key ||= "circuits:#{service}:open"
228
- end
229
-
230
- def half_open_storage_key
231
- @half_open_storage_key ||= "circuits:#{service}:half_open"
268
+ def default_time_klass
269
+ if @circuit_store.is_a?(Circuitbox::MemoryStore)
270
+ Circuitbox::TimeHelper::Monotonic
271
+ else
272
+ Circuitbox::TimeHelper::Real
273
+ end
232
274
  end
233
275
  end
234
276
  end
@@ -1,27 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'memory_store'
4
- require_relative 'timer'
5
4
  require_relative 'notifier/active_support'
6
5
  require_relative 'notifier/null'
7
6
 
8
7
  class Circuitbox
9
8
  module Configuration
10
9
  attr_writer :default_circuit_store,
11
- :default_notifier,
12
- :default_timer,
13
- :default_logger
10
+ :default_notifier
14
11
 
12
+ def self.extended(base)
13
+ base.instance_eval do
14
+ @cached_circuits_mutex = Mutex.new
15
+ @cached_circuits = {}
16
+
17
+ # preload circuit_store because it has no other dependencies
18
+ default_circuit_store
19
+ end
20
+ end
21
+
22
+ # Configure Circuitbox's defaults
23
+ # After configuring the cached circuits are cleared
24
+ #
25
+ # @yieldparam [Circuitbox::Configuration] Circuitbox configuration
26
+ #
15
27
  def configure
16
28
  yield self
17
29
  clear_cached_circuits!
18
30
  nil
19
31
  end
20
32
 
33
+ # Circuit store used by circuits that are not configured with a specific circuit store
34
+ # Defaults to Circuitbox::MemoryStore
35
+ #
36
+ # @return [Circuitbox::MemoryStore, Moneta] Circuit store
21
37
  def default_circuit_store
22
38
  @default_circuit_store ||= MemoryStore.new
23
39
  end
24
40
 
41
+ # Notifier used by circuits that are not configured with a specific notifier.
42
+ # If ActiveSupport::Notifications is defined it defaults to Circuitbox::Notifier::ActiveSupport
43
+ # Otherwise it defaults to Circuitbox::Notifier::Null
44
+ #
45
+ # @return [Circuitbox::Notifier::ActiveSupport, Circuitbox::Notifier::Null] Notifier
25
46
  def default_notifier
26
47
  @default_notifier ||= if defined?(ActiveSupport::Notifications)
27
48
  Notifier::ActiveSupport.new
@@ -30,22 +51,16 @@ class Circuitbox
30
51
  end
31
52
  end
32
53
 
33
- def default_logger
34
- @default_logger ||= if defined?(Rails)
35
- Rails.logger
36
- else
37
- Logger.new($stdout)
38
- end
39
- end
40
-
41
54
  private
42
55
 
43
- def cached_circuits
44
- @cached_circuits ||= {}
56
+ def find_or_create_circuit_breaker(service_name, options)
57
+ @cached_circuits_mutex.synchronize do
58
+ @cached_circuits[service_name] ||= CircuitBreaker.new(service_name, options)
59
+ end
45
60
  end
46
61
 
47
62
  def clear_cached_circuits!
48
- @cached_circuits = {}
63
+ @cached_circuits_mutex.synchronize { @cached_circuits = {} }
49
64
  end
50
65
  end
51
66
  end
@@ -8,5 +8,9 @@ class Circuitbox
8
8
  super()
9
9
  @service = service
10
10
  end
11
+
12
+ def to_s
13
+ "#{self.class}: Service #{service.inspect} has an open circuit"
14
+ end
11
15
  end
12
16
  end
@@ -8,13 +8,13 @@ class Circuitbox
8
8
  super()
9
9
  @service = service
10
10
  @original = exception
11
- # we copy over the original exceptions backtrace if there is one
11
+ # We copy over the original exceptions backtrace if there is one
12
12
  backtrace = exception.backtrace
13
13
  set_backtrace(backtrace) unless backtrace.empty?
14
14
  end
15
15
 
16
16
  def to_s
17
- "#{self.class.name} wrapped: #{original}"
17
+ "#{self.class}: Service #{service.inspect} was unavailable (original: #{original})"
18
18
  end
19
19
  end
20
20
  end
@@ -21,7 +21,7 @@ class Circuitbox
21
21
  open_circuit: lambda do |response|
22
22
  # response.status:
23
23
  # nil -> connection could not be established, or failed very hard
24
- # 5xx -> non recoverable server error, oposed to 4xx which are client errors
24
+ # 5xx -> non recoverable server error, opposed to 4xx which are client errors
25
25
  response.status.nil? || (response.status >= 500 && response.status <= 599)
26
26
  end,
27
27
  default_value: ->(service_response, exception) { NullResponse.new(service_response, exception) },
@@ -34,10 +34,7 @@ class Circuitbox
34
34
  }.freeze
35
35
 
36
36
  DEFAULT_EXCEPTIONS = [
37
- # Faraday before 0.9.0 didn't have Faraday::TimeoutError so we default to Faraday::Error::TimeoutError
38
- # Faraday >= 0.9.0 defines Faraday::TimeoutError and this can be used for all versions up to 1.0.0 that
39
- # also define and raise Faraday::Error::TimeoutError as Faraday::TimeoutError is an ancestor
40
- defined?(Faraday::TimeoutError) ? Faraday::TimeoutError : Faraday::Error::TimeoutError,
37
+ Faraday::TimeoutError,
41
38
  RequestFailed
42
39
  ].freeze
43
40
 
@@ -45,8 +42,6 @@ class Circuitbox
45
42
  exceptions: DEFAULT_EXCEPTIONS
46
43
  }.freeze
47
44
 
48
- attr_reader :opts
49
-
50
45
  def initialize(app, opts = {})
51
46
  @app = app
52
47
  @opts = DEFAULT_OPTIONS.merge(opts)
@@ -70,12 +65,12 @@ class Circuitbox
70
65
  private
71
66
 
72
67
  def call_default_value(response, exception)
73
- default_value = opts[:default_value]
68
+ default_value = @opts[:default_value]
74
69
  default_value.respond_to?(:call) ? default_value.call(response, exception) : default_value
75
70
  end
76
71
 
77
72
  def open_circuit?(response)
78
- opts[:open_circuit].call(response)
73
+ @opts[:open_circuit].call(response)
79
74
  end
80
75
 
81
76
  def circuit_open_value(env, service_response, exception)
@@ -83,12 +78,12 @@ class Circuitbox
83
78
  end
84
79
 
85
80
  def circuit(env)
86
- identifier = opts[:identifier]
81
+ identifier = @opts[:identifier]
87
82
  id = identifier.respond_to?(:call) ? identifier.call(env) : identifier
88
83
 
89
- Circuitbox.circuit(id, opts[:circuit_breaker_options])
84
+ Circuitbox.circuit(id, @opts[:circuit_breaker_options])
90
85
  end
91
86
  end
92
87
  end
93
88
 
94
- Faraday::Middleware.register_middleware circuitbox: Circuitbox::FaradayMiddleware
89
+ Faraday::Middleware.register_middleware(circuitbox: Circuitbox::FaradayMiddleware)
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'monotonic_time'
3
+ require_relative '../time_helper/monotonic'
4
4
 
5
5
  class Circuitbox
6
6
  class MemoryStore
7
7
  class Container
8
- include MonotonicTime
8
+ include TimeHelper::Monotonic
9
9
 
10
10
  attr_accessor :value
11
11
 
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'memory_store/monotonic_time'
3
+ require_relative 'time_helper/monotonic'
4
4
  require_relative 'memory_store/container'
5
5
 
6
6
  class Circuitbox
7
7
  class MemoryStore
8
- include MonotonicTime
8
+ include TimeHelper::Monotonic
9
9
 
10
10
  def initialize(compaction_frequency: 60)
11
11
  @store = {}
@@ -27,7 +27,7 @@ class Circuitbox
27
27
  @mutex.synchronize do
28
28
  existing_container = fetch_container(key)
29
29
 
30
- # reusing the existing container is a small optmization
30
+ # reusing the existing container is a small optimization
31
31
  # to reduce the amount of objects created
32
32
  if existing_container
33
33
  existing_container.expires_after(seconds_to_expire)
@@ -40,7 +40,14 @@ class Circuitbox
40
40
  end
41
41
 
42
42
  def load(key, _opts = {})
43
- @mutex.synchronize { fetch_value(key) }
43
+ @mutex.synchronize { fetch_container(key)&.value }
44
+ end
45
+
46
+ def values_at(*keys, **_opts)
47
+ @mutex.synchronize do
48
+ current_time = current_second
49
+ keys.map! { |key| fetch_container(key, current_time)&.value }
50
+ end
44
51
  end
45
52
 
46
53
  def key?(key)
@@ -53,9 +60,7 @@ class Circuitbox
53
60
 
54
61
  private
55
62
 
56
- def fetch_container(key)
57
- current_time = current_second
58
-
63
+ def fetch_container(key, current_time = current_second)
59
64
  compact(current_time) if @compact_after < current_time
60
65
 
61
66
  container = @store[key]
@@ -70,13 +75,6 @@ class Circuitbox
70
75
  end
71
76
  end
72
77
 
73
- def fetch_value(key)
74
- container = fetch_container(key)
75
- return unless container
76
-
77
- container.value
78
- end
79
-
80
78
  def compact(current_time)
81
79
  @store.delete_if { |_, value| value.expired_at?(current_time) }
82
80
  @compact_after = current_time + @compaction_frequency
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Circuitbox
4
- class Notifier
4
+ module Notifier
5
5
  class ActiveSupport
6
6
  def notify(circuit_name, event)
7
- ::ActiveSupport::Notifications.instrument("circuit_#{event}", circuit: circuit_name)
7
+ ::ActiveSupport::Notifications.instrument("#{event}.circuitbox", circuit: circuit_name)
8
8
  end
9
9
 
10
10
  def notify_warning(circuit_name, message)
11
- ::ActiveSupport::Notifications.instrument('circuit_warning', circuit: circuit_name, message: message)
11
+ ::ActiveSupport::Notifications.instrument('warning.circuitbox', circuit: circuit_name, message: message)
12
12
  end
13
13
 
14
- def metric_gauge(circuit_name, gauge, value)
15
- ::ActiveSupport::Notifications.instrument('circuit_gauge', circuit: circuit_name, gauge: gauge, value: value)
14
+ def notify_run(circuit_name, &block)
15
+ ::ActiveSupport::Notifications.instrument('run.circuitbox', circuit: circuit_name, &block)
16
16
  end
17
17
  end
18
18
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Circuitbox
4
- class Notifier
4
+ module Notifier
5
5
  class Null
6
- def notify(_, _); end
6
+ def notify(_circuit_name, _event); end
7
7
 
8
- def notify_warning(_, _); end
8
+ def notify_warning(_circuit_name, _message); end
9
9
 
10
- def metric_gauge(_, _, _); end
10
+ def notify_run(_circuit_name)
11
+ yield
12
+ end
11
13
  end
12
14
  end
13
15
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Circuitbox
4
- class MemoryStore
5
- module MonotonicTime
4
+ module TimeHelper
5
+ module Monotonic
6
6
  module_function
7
7
 
8
8
  def current_second
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Circuitbox
4
+ module TimeHelper
5
+ module Real
6
+ module_function
7
+
8
+ def current_second
9
+ ::Time.now.to_i
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Circuitbox
4
- VERSION = '2.0.0.pre4'
4
+ VERSION = '2.0.0'
5
5
  end
data/lib/circuitbox.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
-
5
3
  require_relative 'circuitbox/version'
6
4
  require_relative 'circuitbox/circuit_breaker'
7
5
  require_relative 'circuitbox/errors/error'
@@ -10,15 +8,37 @@ require_relative 'circuitbox/errors/service_failure_error'
10
8
  require_relative 'circuitbox/configuration'
11
9
 
12
10
  class Circuitbox
13
- class << self
14
- include Configuration
11
+ extend Configuration
15
12
 
13
+ class << self
14
+ # @overload circuit(service_name, options = {})
15
+ # Returns a Circuitbox::CircuitBreaker for the given service_name
16
+ #
17
+ # @param service_name [String, Symbol] Name of the service
18
+ # Mixing Symbols/Strings for the same service (:test/'test') will result in
19
+ # multiple circuits being created that point to the same service.
20
+ # @param options [Hash] Options for the circuit (See Circuitbox::CircuitBreaker#initialize options)
21
+ # Any configuration options should always be passed when calling this method.
22
+ # @return [Circuitbox::CircuitBreaker] CircuitBreaker for the given service_name
23
+ #
24
+ # @overload circuit(service_name, options = {}, &block)
25
+ # Runs the circuit with the given block
26
+ # The circuit's run method is called with `exception` set to false
27
+ #
28
+ # @param service_name [String, Symbol] Name of the service
29
+ # Mixing Symbols/Strings for the same service (:test/'test') will result in
30
+ # multiple circuits being created that point to the same service.
31
+ # @param options [Hash] Options for the circuit (See Circuitbox::CircuitBreaker#initialize options)
32
+ # Any configuration options should always be passed when calling this method.
33
+ #
34
+ # @return [Object] The result of the block
35
+ # @return [nil] If the circuit is open
16
36
  def circuit(service_name, options, &block)
17
- circuit = (cached_circuits[service_name] ||= CircuitBreaker.new(service_name, options))
37
+ circuit = find_or_create_circuit_breaker(service_name, options)
18
38
 
19
39
  return circuit unless block
20
40
 
21
- circuit.run(circuitbox_exceptions: false, &block)
41
+ circuit.run(exception: false, &block)
22
42
  end
23
43
  end
24
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circuitbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fahim Ferdous
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-01-21 00:00:00.000000000 Z
12
+ date: 2023-05-04 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: bundler
@@ -17,14 +17,14 @@ dependencies:
17
17
  requirements:
18
18
  - - ">"
19
19
  - !ruby/object:Gem::Version
20
- version: '1.16'
20
+ version: '2.0'
21
21
  type: :development
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
25
  - - ">"
26
26
  - !ruby/object:Gem::Version
27
- version: '1.16'
27
+ version: '2.0'
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: excon
30
30
  requirement: !ruby/object:Gem::Requirement
@@ -45,20 +45,14 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '0.8'
49
- - - "<"
50
- - !ruby/object:Gem::Version
51
- version: '2.0'
48
+ version: '0.17'
52
49
  type: :development
53
50
  prerelease: false
54
51
  version_requirements: !ruby/object:Gem::Requirement
55
52
  requirements:
56
53
  - - ">="
57
54
  - !ruby/object:Gem::Version
58
- version: '0.8'
59
- - - "<"
60
- - !ruby/object:Gem::Version
61
- version: '2.0'
55
+ version: '0.17'
62
56
  - !ruby/object:Gem::Dependency
63
57
  name: gimme
64
58
  requirement: !ruby/object:Gem::Requirement
@@ -79,14 +73,14 @@ dependencies:
79
73
  requirements:
80
74
  - - "~>"
81
75
  - !ruby/object:Gem::Version
82
- version: '5.11'
76
+ version: '5.14'
83
77
  type: :development
84
78
  prerelease: false
85
79
  version_requirements: !ruby/object:Gem::Requirement
86
80
  requirements:
87
81
  - - "~>"
88
82
  - !ruby/object:Gem::Version
89
- version: '5.11'
83
+ version: '5.14'
90
84
  - !ruby/object:Gem::Dependency
91
85
  name: minitest-excludes
92
86
  requirement: !ruby/object:Gem::Requirement
@@ -107,14 +101,14 @@ dependencies:
107
101
  requirements:
108
102
  - - "~>"
109
103
  - !ruby/object:Gem::Version
110
- version: '1.7'
104
+ version: '1.12'
111
105
  type: :development
112
106
  prerelease: false
113
107
  version_requirements: !ruby/object:Gem::Requirement
114
108
  requirements:
115
109
  - - "~>"
116
110
  - !ruby/object:Gem::Version
117
- version: '1.7'
111
+ version: '1.12'
118
112
  - !ruby/object:Gem::Dependency
119
113
  name: moneta
120
114
  requirement: !ruby/object:Gem::Requirement
@@ -158,89 +152,61 @@ dependencies:
158
152
  - !ruby/object:Gem::Version
159
153
  version: '13.0'
160
154
  - !ruby/object:Gem::Dependency
161
- name: rubocop
162
- requirement: !ruby/object:Gem::Requirement
163
- requirements:
164
- - - '='
165
- - !ruby/object:Gem::Version
166
- version: 1.8.1
167
- type: :development
168
- prerelease: false
169
- version_requirements: !ruby/object:Gem::Requirement
170
- requirements:
171
- - - '='
172
- - !ruby/object:Gem::Version
173
- version: 1.8.1
174
- - !ruby/object:Gem::Dependency
175
- name: rubocop-minitest
176
- requirement: !ruby/object:Gem::Requirement
177
- requirements:
178
- - - '='
179
- - !ruby/object:Gem::Version
180
- version: 0.10.3
181
- type: :development
182
- prerelease: false
183
- version_requirements: !ruby/object:Gem::Requirement
184
- requirements:
185
- - - '='
186
- - !ruby/object:Gem::Version
187
- version: 0.10.3
188
- - !ruby/object:Gem::Dependency
189
- name: rubocop-performance
155
+ name: timecop
190
156
  requirement: !ruby/object:Gem::Requirement
191
157
  requirements:
192
- - - '='
158
+ - - "~>"
193
159
  - !ruby/object:Gem::Version
194
- version: 1.9.2
160
+ version: '0.9'
195
161
  type: :development
196
162
  prerelease: false
197
163
  version_requirements: !ruby/object:Gem::Requirement
198
164
  requirements:
199
- - - '='
165
+ - - "~>"
200
166
  - !ruby/object:Gem::Version
201
- version: 1.9.2
167
+ version: '0.9'
202
168
  - !ruby/object:Gem::Dependency
203
- name: rubocop-rake
169
+ name: typhoeus
204
170
  requirement: !ruby/object:Gem::Requirement
205
171
  requirements:
206
- - - '='
172
+ - - "~>"
207
173
  - !ruby/object:Gem::Version
208
- version: 0.5.1
174
+ version: '1.4'
209
175
  type: :development
210
176
  prerelease: false
211
177
  version_requirements: !ruby/object:Gem::Requirement
212
178
  requirements:
213
- - - '='
179
+ - - "~>"
214
180
  - !ruby/object:Gem::Version
215
- version: 0.5.1
181
+ version: '1.4'
216
182
  - !ruby/object:Gem::Dependency
217
- name: timecop
183
+ name: webrick
218
184
  requirement: !ruby/object:Gem::Requirement
219
185
  requirements:
220
186
  - - "~>"
221
187
  - !ruby/object:Gem::Version
222
- version: '0.9'
188
+ version: '1.7'
223
189
  type: :development
224
190
  prerelease: false
225
191
  version_requirements: !ruby/object:Gem::Requirement
226
192
  requirements:
227
193
  - - "~>"
228
194
  - !ruby/object:Gem::Version
229
- version: '0.9'
195
+ version: '1.7'
230
196
  - !ruby/object:Gem::Dependency
231
- name: typhoeus
197
+ name: yard
232
198
  requirement: !ruby/object:Gem::Requirement
233
199
  requirements:
234
200
  - - "~>"
235
201
  - !ruby/object:Gem::Version
236
- version: '1.3'
202
+ version: 0.9.26
237
203
  type: :development
238
204
  prerelease: false
239
205
  version_requirements: !ruby/object:Gem::Requirement
240
206
  requirements:
241
207
  - - "~>"
242
208
  - !ruby/object:Gem::Version
243
- version: '1.3'
209
+ version: 0.9.26
244
210
  description:
245
211
  email:
246
212
  - fahimfmf@gmail.com
@@ -252,7 +218,6 @@ files:
252
218
  - README.md
253
219
  - lib/circuitbox.rb
254
220
  - lib/circuitbox/circuit_breaker.rb
255
- - lib/circuitbox/circuit_breaker/logger_messages.rb
256
221
  - lib/circuitbox/configuration.rb
257
222
  - lib/circuitbox/errors/error.rb
258
223
  - lib/circuitbox/errors/open_circuit_error.rb
@@ -261,10 +226,10 @@ files:
261
226
  - lib/circuitbox/faraday_middleware.rb
262
227
  - lib/circuitbox/memory_store.rb
263
228
  - lib/circuitbox/memory_store/container.rb
264
- - lib/circuitbox/memory_store/monotonic_time.rb
265
229
  - lib/circuitbox/notifier/active_support.rb
266
230
  - lib/circuitbox/notifier/null.rb
267
- - lib/circuitbox/timer.rb
231
+ - lib/circuitbox/time_helper/monotonic.rb
232
+ - lib/circuitbox/time_helper/real.rb
268
233
  - lib/circuitbox/version.rb
269
234
  homepage: https://github.com/yammer/circuitbox
270
235
  licenses:
@@ -273,6 +238,7 @@ metadata:
273
238
  bug_tracker_uri: https://github.com/yammer/circuitbox/issues
274
239
  changelog_uri: https://github.com/yammer/circuitbox/blob/main/CHANGELOG.md
275
240
  source_code_uri: https://github.com/yammer/circuitbox
241
+ rubygems_mfa_required: 'true'
276
242
  post_install_message:
277
243
  rdoc_options: []
278
244
  require_paths:
@@ -281,14 +247,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
281
247
  requirements:
282
248
  - - ">="
283
249
  - !ruby/object:Gem::Version
284
- version: 2.4.0
250
+ version: 2.6.0
285
251
  required_rubygems_version: !ruby/object:Gem::Requirement
286
252
  requirements:
287
- - - ">"
253
+ - - ">="
288
254
  - !ruby/object:Gem::Version
289
- version: 1.3.1
255
+ version: '0'
290
256
  requirements: []
291
- rubygems_version: 3.1.4
257
+ rubygems_version: 3.1.6
292
258
  signing_key:
293
259
  specification_version: 4
294
260
  summary: A robust circuit breaker that manages failing external services.
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Circuitbox
4
- class CircuitBreaker
5
- module LoggerMessages
6
- def circuit_skipped_message
7
- @circuit_skipped_message ||= "[CIRCUIT] #{service}: skipped"
8
- end
9
-
10
- def circuit_running_message
11
- @circuit_running_message ||= "[CIRCUIT] #{service}: running"
12
- end
13
-
14
- def circuit_success_message
15
- @circuit_success_message ||= "[CIRCUIT] #{service}: success"
16
- end
17
-
18
- def circuit_failure_message
19
- @circuit_failure_message ||= "[CIRCUIT] #{service}: failure"
20
- end
21
-
22
- def circuit_opened_message
23
- @circuit_opened_message ||= "[CIRCUIT] #{service}: opened"
24
- end
25
-
26
- def circuit_closed_message
27
- @circuit_closed_message ||= "[CIRCUIT] #{service}: closed"
28
- end
29
- end
30
- end
31
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Circuitbox
4
- class Timer
5
- class Monotonic
6
- class << self
7
- def supported?
8
- defined?(Process::CLOCK_MONOTONIC)
9
- end
10
-
11
- def now
12
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
- end
14
- end
15
- end
16
-
17
- class Default
18
- class << self
19
- def supported?
20
- true
21
- end
22
-
23
- def now
24
- Time.now.to_f
25
- end
26
- end
27
- end
28
-
29
- class << self
30
- def measure(service, notifier, metric_name)
31
- before = now
32
- result = yield
33
- total_time = now - before
34
- notifier.metric_gauge(service, metric_name, total_time)
35
- result
36
- end
37
-
38
- private
39
-
40
- if Monotonic.supported?
41
- def now
42
- Monotonic.now
43
- end
44
- else
45
- def now
46
- Default.now
47
- end
48
- end
49
- end
50
- end
51
- end