faulty 0.1.4 → 0.5.1

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.rubocop.yml +9 -0
  4. data/CHANGELOG.md +55 -0
  5. data/Gemfile +8 -3
  6. data/README.md +883 -310
  7. data/bin/check-version +5 -1
  8. data/faulty.gemspec +1 -1
  9. data/lib/faulty.rb +167 -43
  10. data/lib/faulty/cache.rb +3 -1
  11. data/lib/faulty/cache/auto_wire.rb +58 -0
  12. data/lib/faulty/cache/circuit_proxy.rb +61 -0
  13. data/lib/faulty/cache/default.rb +10 -21
  14. data/lib/faulty/cache/fault_tolerant_proxy.rb +15 -4
  15. data/lib/faulty/cache/interface.rb +1 -1
  16. data/lib/faulty/cache/mock.rb +1 -1
  17. data/lib/faulty/cache/null.rb +1 -1
  18. data/lib/faulty/cache/rails.rb +9 -10
  19. data/lib/faulty/circuit.rb +31 -16
  20. data/lib/faulty/error.rb +29 -7
  21. data/lib/faulty/events.rb +1 -1
  22. data/lib/faulty/events/callback_listener.rb +1 -1
  23. data/lib/faulty/events/honeybadger_listener.rb +1 -1
  24. data/lib/faulty/events/listener_interface.rb +1 -1
  25. data/lib/faulty/events/log_listener.rb +5 -6
  26. data/lib/faulty/events/notifier.rb +1 -1
  27. data/lib/faulty/immutable_options.rb +1 -1
  28. data/lib/faulty/patch.rb +154 -0
  29. data/lib/faulty/patch/base.rb +46 -0
  30. data/lib/faulty/patch/redis.rb +60 -0
  31. data/lib/faulty/result.rb +2 -2
  32. data/lib/faulty/status.rb +3 -2
  33. data/lib/faulty/storage.rb +4 -1
  34. data/lib/faulty/storage/auto_wire.rb +107 -0
  35. data/lib/faulty/storage/circuit_proxy.rb +64 -0
  36. data/lib/faulty/storage/fallback_chain.rb +207 -0
  37. data/lib/faulty/storage/fault_tolerant_proxy.rb +52 -57
  38. data/lib/faulty/storage/interface.rb +3 -2
  39. data/lib/faulty/storage/memory.rb +8 -4
  40. data/lib/faulty/storage/redis.rb +75 -13
  41. data/lib/faulty/version.rb +2 -2
  42. metadata +14 -7
  43. data/.travis.yml +0 -46
  44. data/lib/faulty/scope.rb +0 -117
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Cache
5
5
  # A wrapper for cache backends that may raise errors
6
6
  #
7
- # {Scope} automatically wraps all non-fault-tolerant cache backends with
7
+ # {Faulty#initialize} automatically wraps all non-fault-tolerant cache backends with
8
8
  # this class.
9
9
  #
10
10
  # If the cache backend raises a `StandardError`, it will be captured and
11
- # sent to the notifier.
11
+ # sent to the notifier. Reads errors will return `nil`, and writes will be
12
+ # a no-op.
12
13
  class FaultTolerantProxy
13
14
  attr_reader :options
14
15
 
@@ -36,6 +37,16 @@ module Faulty
36
37
  @options = Options.new(options, &block)
37
38
  end
38
39
 
40
+ # Wrap a cache in a FaultTolerantProxy unless it's already fault tolerant
41
+ #
42
+ # @param cache [Cache::Interface] The cache to maybe wrap
43
+ # @return [Cache::Interface] The original cache or a {FaultTolerantProxy}
44
+ def self.wrap(cache, **options, &block)
45
+ return cache if cache.fault_tolerant?
46
+
47
+ new(cache, **options, &block)
48
+ end
49
+
39
50
  # Read from the cache safely
40
51
  #
41
52
  # If the backend raises a `StandardError`, this will return `nil`.
@@ -58,7 +69,7 @@ module Faulty
58
69
  # @return [void]
59
70
  def write(key, value, expires_in: nil)
60
71
  @cache.write(key, value, expires_in: expires_in)
61
- rescue StandardError
72
+ rescue StandardError => e
62
73
  options.notifier.notify(:cache_failure, key: key, action: :write, error: e)
63
74
  nil
64
75
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Cache
5
5
  # The interface required for a cache backend implementation
6
6
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Cache
5
5
  # A mock cache for testing
6
6
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Cache
5
5
  # A cache backend that does nothing
6
6
  #
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Cache
5
5
  # A wrapper for a Rails or ActiveSupport cache
6
6
  #
7
7
  class Rails
8
+ extend Forwardable
9
+
8
10
  # @param cache The Rails cache to wrap
9
11
  # @param fault_tolerant [Boolean] Whether the Rails cache is
10
12
  # fault_tolerant. See {#fault_tolerant?} for more details
@@ -13,15 +15,12 @@ module Faulty
13
15
  @fault_tolerant = fault_tolerant
14
16
  end
15
17
 
16
- # (see Interface#read)
17
- def read(key)
18
- @cache.read(key)
19
- end
20
-
21
- # (see Interface#read)
22
- def write(key, value, expires_in: nil)
23
- @cache.write(key, value, expires_in: expires_in)
24
- end
18
+ # @!method read(key)
19
+ # (see Faulty::Cache::Interface#read)
20
+ #
21
+ # @!method write(key, value, expires_in: expires_in)
22
+ # (see Faulty::Cache::Interface#write)
23
+ def_delegators :@cache, :read, :write
25
24
 
26
25
  # Although ActiveSupport cache implementations are fault-tolerant,
27
26
  # Rails.cache is not guranteed to be fault tolerant. For this reason,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # Runs code protected by a circuit breaker
5
5
  #
6
6
  # https://www.martinfowler.com/bliki/CircuitBreaker.html
@@ -50,6 +50,9 @@ module Faulty
50
50
  # @!attribute [r] cool_down
51
51
  # @return [Integer] The number of seconds the circuit will
52
52
  # stay open after it is tripped. Default 300.
53
+ # @!attribute [r] error_module
54
+ # @return [Module] Used by patches to set the namespace module for
55
+ # the faulty errors that will be raised. Default `Faulty`
53
56
  # @!attribute [r] evaluation_window
54
57
  # @return [Integer] The number of seconds of history that
55
58
  # will be evaluated to determine the failure rate for a circuit.
@@ -66,14 +69,19 @@ module Faulty
66
69
  # @return [Error, Array<Error>] An array of errors that are considered circuit
67
70
  # failures. Default `[StandardError]`.
68
71
  # @!attribute [r] exclude
69
- # @return [Error, Array<Error>] An array of errors that will be captured and
70
- # considered circuit failures. Default `[]`.
72
+ # @return [Error, Array<Error>] An array of errors that will not be
73
+ # captured by Faulty. These errors will not be considered circuit
74
+ # failures. Default `[]`.
71
75
  # @!attribute [r] cache
72
- # @return [Cache::Interface] The cache backend. Default `Cache::Null.new`
76
+ # @return [Cache::Interface] The cache backend. Default
77
+ # `Cache::Null.new`. Unlike {Faulty#initialize}, this is not wrapped in
78
+ # {Cache::AutoWire} by default.
73
79
  # @!attribute [r] notifier
74
80
  # @return [Events::Notifier] A Faulty notifier. Default `Events::Notifier.new`
75
81
  # @!attribute [r] storage
76
- # @return [Storage::Interface] The storage backend. Default `Storage::Memory.new`
82
+ # @return [Storage::Interface] The storage backend. Default
83
+ # `Storage::Memory.new`. Unlike {Faulty#initialize}, this is not wrapped
84
+ # in {Storage::AutoWire} by default.
77
85
  Options = Struct.new(
78
86
  :cache_expires_in,
79
87
  :cache_refreshes_after,
@@ -83,6 +91,7 @@ module Faulty
83
91
  :rate_threshold,
84
92
  :sample_threshold,
85
93
  :errors,
94
+ :error_module,
86
95
  :exclude,
87
96
  :cache,
88
97
  :notifier,
@@ -98,6 +107,7 @@ module Faulty
98
107
  cache_refreshes_after: 900,
99
108
  cool_down: 300,
100
109
  errors: [StandardError],
110
+ error_module: Faulty,
101
111
  exclude: [],
102
112
  evaluation_window: 60,
103
113
  rate_threshold: 0.5,
@@ -110,6 +120,7 @@ module Faulty
110
120
  cache
111
121
  cool_down
112
122
  errors
123
+ error_module
113
124
  exclude
114
125
  evaluation_window
115
126
  rate_threshold
@@ -208,9 +219,11 @@ module Faulty
208
219
  cached_value = cache_read(cache)
209
220
  # return cached unless cached.nil?
210
221
  return cached_value if !cached_value.nil? && !cache_should_refresh?(cache)
211
- return run_skipped(cached_value) unless status.can_run?
212
222
 
213
- run_exec(cached_value, cache, &block)
223
+ current_status = status
224
+ return run_skipped(cached_value) unless current_status.can_run?
225
+
226
+ run_exec(current_status, cached_value, cache, &block)
214
227
  end
215
228
 
216
229
  # Force the circuit to stay open until unlocked
@@ -277,7 +290,7 @@ module Faulty
277
290
  # @return The result from cache if available
278
291
  def run_skipped(cached_value)
279
292
  skipped!
280
- raise OpenCircuitError.new(nil, self) if cached_value.nil?
293
+ raise options.error_module::OpenCircuitError.new(nil, self) if cached_value.nil?
281
294
 
282
295
  cached_value
283
296
  end
@@ -287,26 +300,27 @@ module Faulty
287
300
  # @param cached_value The cached value if one is available
288
301
  # @param cache_key [String, nil] The cache key if one is given
289
302
  # @return The run result
290
- def run_exec(cached_value, cache_key)
303
+ def run_exec(status, cached_value, cache_key)
291
304
  result = yield
292
- success!
305
+ success!(status)
293
306
  cache_write(cache_key, result)
294
307
  result
295
308
  rescue *options.errors => e
296
309
  raise if options.exclude.any? { |ex| e.is_a?(ex) }
297
310
 
298
311
  if cached_value.nil?
299
- raise CircuitTrippedError.new(nil, self) if failure!(e)
312
+ raise options.error_module::CircuitTrippedError.new(nil, self) if failure!(status, e)
300
313
 
301
- raise CircuitFailureError.new(nil, self)
314
+ raise options.error_module::CircuitFailureError.new(nil, self)
302
315
  else
303
316
  cached_value
304
317
  end
305
318
  end
306
319
 
307
320
  # @return [Boolean] True if the circuit transitioned to closed
308
- def success!
309
- status = storage.entry(self, Faulty.current_time, true)
321
+ def success!(status)
322
+ entries = storage.entry(self, Faulty.current_time, true)
323
+ status = Status.from_entries(entries, **status.to_h)
310
324
  closed = false
311
325
  closed = close! if should_close?(status)
312
326
 
@@ -315,8 +329,9 @@ module Faulty
315
329
  end
316
330
 
317
331
  # @return [Boolean] True if the circuit transitioned to open
318
- def failure!(error)
319
- status = storage.entry(self, Faulty.current_time, false)
332
+ def failure!(status, error)
333
+ entries = storage.entry(self, Faulty.current_time, false)
334
+ status = Status.from_entries(entries, **status.to_h)
320
335
  options.notifier.notify(:circuit_failure, circuit: self, status: status, error: error)
321
336
 
322
337
  opened = if status.half_open?
data/lib/faulty/error.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The base error for all Faulty errors
5
5
  class FaultyError < StandardError; end
6
6
 
@@ -20,19 +20,21 @@ module Faulty
20
20
  end
21
21
  end
22
22
 
23
- # Raised if getting the default scope without initializing one
24
- class MissingDefaultScopeError < FaultyError
23
+ # Raised if getting the default instance without initializing one
24
+ class MissingDefaultInstanceError < FaultyError
25
25
  def initialize(message = nil)
26
- message ||= 'No default scope. Create one with init or get your scope with Faulty[:scope_name]'
26
+ message ||= 'No default instance. Create one with init or get your instance with Faulty[:name]'
27
27
  super(message)
28
28
  end
29
29
  end
30
30
 
31
- # The base error for all errors raised during circuit runs
32
- #
33
- class CircuitError < FaultyError
31
+ # Included in faulty circuit errors to provide common features for
32
+ # native and patched errors
33
+ module CircuitErrorBase
34
34
  attr_reader :circuit
35
35
 
36
+ # @param message [String]
37
+ # @param circuit [Circuit] The circuit that raised the error
36
38
  def initialize(message, circuit)
37
39
  message ||= %(circuit error for "#{circuit.name}")
38
40
  @circuit = circuit
@@ -41,6 +43,12 @@ module Faulty
41
43
  end
42
44
  end
43
45
 
46
+ # The base error for all errors raised during circuit runs
47
+ #
48
+ class CircuitError < FaultyError
49
+ include CircuitErrorBase
50
+ end
51
+
44
52
  # Raised when running a circuit that is already open
45
53
  class OpenCircuitError < CircuitError; end
46
54
 
@@ -59,8 +67,22 @@ module Faulty
59
67
  # Raised if calling get or error on a result without checking it
60
68
  class UncheckedResultError < FaultyError; end
61
69
 
70
+ # An error that wraps multiple other errors
71
+ class FaultyMultiError < FaultyError
72
+ def initialize(message, errors)
73
+ message = "#{message}: #{errors.map(&:message).join(', ')}"
74
+ super(message)
75
+ end
76
+ end
77
+
62
78
  # Raised if getting the wrong result type.
63
79
  #
64
80
  # For example, calling get on an error result will raise this
65
81
  class WrongResultError < FaultyError; end
82
+
83
+ # Raised if a FallbackChain partially fails
84
+ class PartialFailureError < FaultyMultiError; end
85
+
86
+ # Raised if all FallbackChain backends fail
87
+ class AllFailedError < FaultyMultiError; end
66
88
  end
data/lib/faulty/events.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The namespace for Faulty events and event listeners
5
5
  module Events
6
6
  # All possible events that can be raised by Faulty
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Events
5
5
  # A simple listener implementation that uses callback blocks as handlers
6
6
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Events
5
5
  # Reports circuit errors to Honeybadger
6
6
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Events
5
5
  # The interface required to implement a event listener
6
6
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Events
5
5
  # A default listener that logs Faulty events
6
6
  class LogListener
@@ -72,11 +72,10 @@ module Faulty
72
72
  end
73
73
 
74
74
  def storage_failure(payload)
75
- log(
76
- :error, 'Storage failure', payload[:action],
77
- circuit: payload[:circuit]&.name,
78
- error: payload[:error].message
79
- )
75
+ extra = {}
76
+ extra[:circuit] = payload[:circuit].name if payload.key?(:circuit)
77
+ extra[:error] = payload[:error].message
78
+ log(:error, 'Storage failure', payload[:action], extra)
80
79
  end
81
80
 
82
81
  def log(level, msg, action, extra = {})
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Events
5
5
  # The default event dispatcher for Faulty
6
6
  class Notifier
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # A struct that cannot be modified after initialization
5
5
  module ImmutableOptions
6
6
  # @param hash [Hash] A hash of attributes to initialize with
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faulty/patch/base'
4
+
5
+ class Faulty
6
+ # Automatic wrappers for common core dependencies like database connections
7
+ # or caches
8
+ module Patch
9
+ class << self
10
+ # Create a circuit from a configuration hash
11
+ #
12
+ # This is intended to be used in contexts where the user passes in
13
+ # something like a connection hash to a third-party library. For example
14
+ # the Redis patch hooks into the normal Redis connection options to add
15
+ # a `:faulty` key, which is a hash of faulty circuit options. This is
16
+ # slightly different from the normal Faulty circuit options because
17
+ # we also accept an `:instance` key which is a faulty instance.
18
+ #
19
+ # @example
20
+ # # We pass in a faulty instance along with some circuit options
21
+ # Patch.circuit_from_hash(
22
+ # :mysql,
23
+ # { host: 'localhost', faulty: {
24
+ # name: 'my_mysql', # A custom circuit name can be included
25
+ # instance: Faulty.new,
26
+ # sample_threshold: 5
27
+ # }
28
+ # }
29
+ # )
30
+ #
31
+ # @example
32
+ # # instance can be a registered faulty instance referenced by a string
33
+ # or symbol
34
+ # Faulty.register(:db_faulty, Faulty.new)
35
+ # Patch.circuit_from_hash(
36
+ # :mysql,
37
+ # { host: 'localhost', faulty: { instance: :db_faulty } }
38
+ # )
39
+ # @example
40
+ # # If instance is a hash with the key :constant, the value can be
41
+ # # a global constant name containing a Faulty instance
42
+ # DB_FAULTY = Faulty.new
43
+ # Patch.circuit_from_hash(
44
+ # :mysql,
45
+ # { host: 'localhost', faulty: { instance: { constant: 'DB_FAULTY' } } }
46
+ # )
47
+ #
48
+ # @example
49
+ # # Certain patches may want to enforce certain options like :errors
50
+ # # This can be done via hash or the usual block syntax
51
+ # Patch.circuit_from_hash(:mysql,
52
+ # { host: 'localhost', faulty: {} }
53
+ # errors: [Mysql2::Error]
54
+ # )
55
+ #
56
+ # Patch.circuit_from_hash(:mysql,
57
+ # { host: 'localhost', faulty: {} }
58
+ # ) do |conf|
59
+ # conf.errors = [Mysql2::Error]
60
+ # end
61
+ #
62
+ # @param default_name [String] The default name for the circuit
63
+ # @param hash [Hash] A hash of user-provided options. Supports any circuit
64
+ # option and these additional options
65
+ # @option hash [String] :name The circuit name. Defaults to `default_name`
66
+ # @option hash [Boolean] :patch_errors By default, circuit errors will be
67
+ # subclasses of `options[:patched_error_module]`. The user can disable
68
+ # this by setting this option to false.
69
+ # @option hash [Faulty, String, Symbol, Hash{ constant: String }] :instance
70
+ # A reference to a faulty instance. See examples.
71
+ # @param options [Hash] Additional override options. Supports any circuit
72
+ # option and these additional ones.
73
+ # @option options [Module] :patched_error_module The namespace module
74
+ # for patched errors
75
+ # @yield [Circuit::Options] For setting override options in a block
76
+ # @return [Circuit, nil] The circuit if one was created
77
+ def circuit_from_hash(default_name, hash, **options, &block)
78
+ return unless hash
79
+
80
+ hash = symbolize_keys(hash)
81
+ name = hash.delete(:name) || default_name
82
+ patch_errors = hash.delete(:patch_errors) != false
83
+ error_module = options.delete(:patched_error_module)
84
+ hash[:error_module] ||= error_module if error_module && patch_errors
85
+ faulty = resolve_instance(hash.delete(:instance))
86
+ faulty.circuit(name, **hash, **options, &block)
87
+ end
88
+
89
+ # Create a full set of {CircuitError}s with a given base error class
90
+ #
91
+ # For patches that need their errors to be subclasses of a common base.
92
+ #
93
+ # @param namespace [Module] The module to define the error classes in
94
+ # @param base [Class] The base class for the error classes
95
+ # @return [void]
96
+ def define_circuit_errors(namespace, base)
97
+ circuit_error = Class.new(base) { include CircuitErrorBase }
98
+ namespace.const_set('CircuitError', circuit_error)
99
+ namespace.const_set('OpenCircuitError', Class.new(circuit_error))
100
+ namespace.const_set('CircuitFailureError', Class.new(circuit_error))
101
+ namespace.const_set('CircuitTrippedError', Class.new(circuit_error))
102
+ end
103
+
104
+ private
105
+
106
+ # Resolves a constant from a constant name or returns a default
107
+ #
108
+ # - If value is a string or symbol, gets a registered Faulty instance with that name
109
+ # - If value is a Hash with a key `:constant`, resolves the value to a global constant
110
+ # - If value is nil, gets Faulty.default
111
+ # - Otherwise, return value directly
112
+ #
113
+ # @param value [String, Symbol, Faulty, nil] The object or constant name to resolve
114
+ # @return [Object] The resolved Faulty instance
115
+ def resolve_instance(value)
116
+ case value
117
+ when String, Symbol
118
+ result = Faulty[value]
119
+ raise NameError, "No Faulty instance for #{value}" unless result
120
+
121
+ result
122
+ when Hash
123
+ const_name = value[:constant]
124
+ raise ArgumentError 'Missing hash key :constant for Faulty instance' unless const_name
125
+
126
+ Kernel.const_get(const_name)
127
+ when nil
128
+ Faulty.default
129
+ else
130
+ value
131
+ end
132
+ end
133
+
134
+ # Some config files may not suport symbol keys, so we convert the hash
135
+ # to use symbols so that users can pass in strings
136
+ #
137
+ # We cannot use transform_keys since we support Ruby < 2.5
138
+ #
139
+ # @param hash [Hash] A hash to convert
140
+ # @return [Hash] The hash with keys as symbols
141
+ def symbolize_keys(hash)
142
+ result = {}
143
+ hash.each do |key, val|
144
+ result[key.to_sym] = if val.is_a?(Hash)
145
+ symbolize_keys(val)
146
+ else
147
+ val
148
+ end
149
+ end
150
+ result
151
+ end
152
+ end
153
+ end
154
+ end