faulty 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -0
  3. data/.travis.yml +4 -2
  4. data/CHANGELOG.md +37 -1
  5. data/Gemfile +17 -0
  6. data/README.md +333 -55
  7. data/bin/check-version +5 -1
  8. data/bin/console +1 -1
  9. data/faulty.gemspec +3 -10
  10. data/lib/faulty.rb +149 -43
  11. data/lib/faulty/cache.rb +3 -1
  12. data/lib/faulty/cache/auto_wire.rb +65 -0
  13. data/lib/faulty/cache/circuit_proxy.rb +61 -0
  14. data/lib/faulty/cache/default.rb +10 -21
  15. data/lib/faulty/cache/fault_tolerant_proxy.rb +15 -4
  16. data/lib/faulty/cache/interface.rb +1 -1
  17. data/lib/faulty/cache/mock.rb +1 -1
  18. data/lib/faulty/cache/null.rb +1 -1
  19. data/lib/faulty/cache/rails.rb +9 -10
  20. data/lib/faulty/circuit.rb +10 -5
  21. data/lib/faulty/error.rb +18 -4
  22. data/lib/faulty/events.rb +3 -2
  23. data/lib/faulty/events/callback_listener.rb +1 -1
  24. data/lib/faulty/events/honeybadger_listener.rb +53 -0
  25. data/lib/faulty/events/listener_interface.rb +1 -1
  26. data/lib/faulty/events/log_listener.rb +1 -1
  27. data/lib/faulty/events/notifier.rb +11 -2
  28. data/lib/faulty/immutable_options.rb +1 -1
  29. data/lib/faulty/result.rb +2 -2
  30. data/lib/faulty/status.rb +1 -1
  31. data/lib/faulty/storage.rb +4 -1
  32. data/lib/faulty/storage/auto_wire.rb +122 -0
  33. data/lib/faulty/storage/circuit_proxy.rb +64 -0
  34. data/lib/faulty/storage/fallback_chain.rb +207 -0
  35. data/lib/faulty/storage/fault_tolerant_proxy.rb +55 -60
  36. data/lib/faulty/storage/interface.rb +1 -1
  37. data/lib/faulty/storage/memory.rb +8 -4
  38. data/lib/faulty/storage/redis.rb +75 -13
  39. data/lib/faulty/version.rb +2 -2
  40. metadata +13 -118
  41. 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
@@ -66,14 +66,19 @@ module Faulty
66
66
  # @return [Error, Array<Error>] An array of errors that are considered circuit
67
67
  # failures. Default `[StandardError]`.
68
68
  # @!attribute [r] exclude
69
- # @return [Error, Array<Error>] An array of errors that will be captured and
70
- # considered circuit failures. Default `[]`.
69
+ # @return [Error, Array<Error>] An array of errors that will not be
70
+ # captured by Faulty. These errors will not be considered circuit
71
+ # failures. Default `[]`.
71
72
  # @!attribute [r] cache
72
- # @return [Cache::Interface] The cache backend. Default `Cache::Null.new`
73
+ # @return [Cache::Interface] The cache backend. Default
74
+ # `Cache::Null.new`. Unlike {Faulty#initialize}, this is not wrapped in
75
+ # {Cache::AutoWire} by default.
73
76
  # @!attribute [r] notifier
74
77
  # @return [Events::Notifier] A Faulty notifier. Default `Events::Notifier.new`
75
78
  # @!attribute [r] storage
76
- # @return [Storage::Interface] The storage backend. Default `Storage::Memory.new`
79
+ # @return [Storage::Interface] The storage backend. Default
80
+ # `Storage::Memory.new`. Unlike {Faulty#initialize}, this is not wrapped
81
+ # in {Storage::AutoWire} by default.
77
82
  Options = Struct.new(
78
83
  :cache_expires_in,
79
84
  :cache_refreshes_after,
@@ -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,10 +20,10 @@ 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
@@ -59,8 +59,22 @@ module Faulty
59
59
  # Raised if calling get or error on a result without checking it
60
60
  class UncheckedResultError < FaultyError; end
61
61
 
62
+ # An error that wraps multiple other errors
63
+ class FaultyMultiError < FaultyError
64
+ def initialize(message, errors)
65
+ message = "#{message}: #{errors.map(&:message).join(', ')}"
66
+ super(message)
67
+ end
68
+ end
69
+
62
70
  # Raised if getting the wrong result type.
63
71
  #
64
72
  # For example, calling get on an error result will raise this
65
73
  class WrongResultError < FaultyError; end
74
+
75
+ # Raised if a FallbackChain partially fails
76
+ class PartialFailureError < FaultyMultiError; end
77
+
78
+ # Raised if all FallbackChain backends fail
79
+ class AllFailedError < FaultyMultiError; end
66
80
  end
@@ -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
@@ -21,5 +21,6 @@ module Faulty
21
21
  end
22
22
 
23
23
  require 'faulty/events/callback_listener'
24
- require 'faulty/events/notifier'
24
+ require 'faulty/events/honeybadger_listener'
25
25
  require 'faulty/events/log_listener'
26
+ require 'faulty/events/notifier'
@@ -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
  #
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Events
5
+ # Reports circuit errors to Honeybadger
6
+ #
7
+ # https://www.honeybadger.io/
8
+ #
9
+ # The honeybadger gem must be available.
10
+ class HoneybadgerListener
11
+ # (see ListenerInterface#handle)
12
+ def handle(event, payload)
13
+ return unless EVENTS.include?(event)
14
+
15
+ send(event, payload) if respond_to?(event, true)
16
+ end
17
+
18
+ private
19
+
20
+ def circuit_failure(payload)
21
+ _circuit_error(payload)
22
+ end
23
+
24
+ def circuit_opened(payload)
25
+ _circuit_error(payload)
26
+ end
27
+
28
+ def circuit_reopened(payload)
29
+ _circuit_error(payload)
30
+ end
31
+
32
+ def cache_failure(payload)
33
+ Honeybadger.notify(payload[:error], context: {
34
+ action: payload[:action],
35
+ key: payload[:key]
36
+ })
37
+ end
38
+
39
+ def storage_failure(payload)
40
+ Honeybadger.notify(payload[:error], context: {
41
+ action: payload[:action],
42
+ circuit: payload[:circuit]&.name
43
+ })
44
+ end
45
+
46
+ def _circuit_error(payload)
47
+ Honeybadger.notify(payload[:error], context: {
48
+ circuit: payload[:circuit].name
49
+ })
50
+ end
51
+ end
52
+ end
53
+ end
@@ -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
@@ -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
@@ -11,6 +11,9 @@ module Faulty
11
11
 
12
12
  # Notify all listeners of an event
13
13
  #
14
+ # If a listener raises an error while handling an event, that error will
15
+ # be captured and written to STDERR.
16
+ #
14
17
  # @param event [Symbol] The event name
15
18
  # @param payload [Hash] A hash of event payload data. The payload keys
16
19
  # differ between events, but should be consistent across calls for a
@@ -18,7 +21,13 @@ module Faulty
18
21
  def notify(event, payload)
19
22
  raise ArgumentError, "Unknown event #{event}" unless EVENTS.include?(event)
20
23
 
21
- @listeners.each { |l| l.handle(event, payload) }
24
+ @listeners.each do |listener|
25
+ begin
26
+ listener.handle(event, payload)
27
+ rescue StandardError => e
28
+ warn "Faulty listener #{listener.class.name} crashed: #{e.message}"
29
+ end
30
+ end
22
31
  end
23
32
  end
24
33
  end
@@ -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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # An approximation of the `Result` type from some strongly-typed languages.
5
5
  #
6
6
  # F#: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/results
@@ -53,7 +53,7 @@ module Faulty
53
53
  #
54
54
  # @param ok An ok value
55
55
  # @param error [Error] An error instance
56
- def initialize(ok: NOTHING, error: NOTHING) # rubocop:disable Naming/MethodParameterName
56
+ def initialize(ok: NOTHING, error: NOTHING)
57
57
  if ok.equal?(NOTHING) && error.equal?(NOTHING)
58
58
  raise ArgumentError, 'Result must have an ok or error value'
59
59
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The status of a circuit
5
5
  #
6
6
  # Includes information like the state and locks. Also calculates
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The namespace for Faulty storage
5
5
  module Storage
6
6
  end
7
7
  end
8
8
 
9
+ require 'faulty/storage/auto_wire'
10
+ require 'faulty/storage/circuit_proxy'
11
+ require 'faulty/storage/fallback_chain'
9
12
  require 'faulty/storage/fault_tolerant_proxy'
10
13
  require 'faulty/storage/memory'
11
14
  require 'faulty/storage/redis'
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Storage
5
+ # Automatically configure a storage backend
6
+ #
7
+ # Used by {Faulty#initialize} to setup sensible storage defaults
8
+ class AutoWire
9
+ extend Forwardable
10
+
11
+ # Options for {AutoWire}
12
+ Options = Struct.new(
13
+ :notifier
14
+ ) do
15
+ include ImmutableOptions
16
+
17
+ private
18
+
19
+ def required
20
+ %i[notifier]
21
+ end
22
+ end
23
+
24
+ # Wrap storage backends with sensible defaults
25
+ #
26
+ # If the cache is `nil`, create a new {Memory} storage.
27
+ #
28
+ # If a single storage backend is given and is fault tolerant, leave it
29
+ # unmodified.
30
+ #
31
+ # If a single storage backend is given and is not fault tolerant, wrap it
32
+ # in a {CircuitProxy} and a {FaultTolerantProxy}.
33
+ #
34
+ # If an array of storage backends is given, wrap each non-fault-tolerant
35
+ # entry in a {CircuitProxy} and create a {FallbackChain}. If none of the
36
+ # backends in the array are fault tolerant, also wrap the {FallbackChain}
37
+ # in a {FaultTolerantProxy}.
38
+ #
39
+ # @todo Consider using a {FallbackChain} for non-fault-tolerant storages
40
+ # by default. This would fallback to a {Memory} storage. It would
41
+ # require a more conservative implementation of {Memory} that could
42
+ # limit the number of circuits stored. For now, users need to manually
43
+ # configure fallbacks.
44
+ #
45
+ # @param storage [Interface, Array<Interface>] A storage backed or array
46
+ # of storage backends to setup.
47
+ # @param options [Hash] Attributes for {Options}
48
+ # @yield [Options] For setting options in a block
49
+ def initialize(storage, **options, &block)
50
+ @options = Options.new(options, &block)
51
+ @storage = if storage.nil?
52
+ Memory.new
53
+ elsif storage.is_a?(Array)
54
+ wrap_array(storage)
55
+ elsif !storage.fault_tolerant?
56
+ wrap_one(storage)
57
+ else
58
+ storage
59
+ end
60
+
61
+ freeze
62
+ end
63
+
64
+ # @!method entry(circuit, time, success)
65
+ # (see Faulty::Storage::Interface#entry)
66
+ #
67
+ # @!method open(circuit, opened_at)
68
+ # (see Faulty::Storage::Interface#open)
69
+ #
70
+ # @!method reopen(circuit, opened_at, previous_opened_at)
71
+ # (see Faulty::Storage::Interface#reopen)
72
+ #
73
+ # @!method close(circuit)
74
+ # (see Faulty::Storage::Interface#close)
75
+ #
76
+ # @!method lock(circuit, state)
77
+ # (see Faulty::Storage::Interface#lock)
78
+ #
79
+ # @!method unlock(circuit)
80
+ # (see Faulty::Storage::Interface#unlock)
81
+ #
82
+ # @!method reset(circuit)
83
+ # (see Faulty::Storage::Interface#reset)
84
+ #
85
+ # @!method status(circuit)
86
+ # (see Faulty::Storage::Interface#status)
87
+ #
88
+ # @!method history(circuit)
89
+ # (see Faulty::Storage::Interface#history)
90
+ #
91
+ # @!method list
92
+ # (see Faulty::Storage::Interface#list)
93
+ #
94
+ def_delegators :@storage,
95
+ :entry, :open, :reopen, :close, :lock,
96
+ :unlock, :reset, :status, :history, :list
97
+
98
+ def fault_tolerant?
99
+ true
100
+ end
101
+
102
+ private
103
+
104
+ # Wrap an array of storage backends in a fault-tolerant FallbackChain
105
+ #
106
+ # @return [Storage::Interface] A fault-tolerant fallback chain
107
+ def wrap_array(array)
108
+ FaultTolerantProxy.wrap(FallbackChain.new(
109
+ array.map { |s| s.fault_tolerant? ? s : CircuitProxy.new(s, notifier: @options.notifier) },
110
+ notifier: @options.notifier
111
+ ), notifier: @options.notifier)
112
+ end
113
+
114
+ def wrap_one(storage)
115
+ FaultTolerantProxy.new(
116
+ CircuitProxy.new(storage, notifier: @options.notifier),
117
+ notifier: @options.notifier
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end