faulty 0.1.1 → 0.3.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.
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