faulty 0.1.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.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ # The base error for all Faulty errors
5
+ class FaultyError < StandardError; end
6
+
7
+ # Raised if using the global Faulty object without initializing it
8
+ class UninitializedError < FaultyError
9
+ def initialize(message = nil)
10
+ message ||= 'Faulty is not initialized'
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # Raised if {Faulty.init} is called multiple times
16
+ class AlreadyInitializedError < FaultyError
17
+ def initialize(message = nil)
18
+ message ||= 'Faulty is already initialized'
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # Raised if getting the default scope without initializing one
24
+ class MissingDefaultScopeError < FaultyError
25
+ def initialize(message = nil)
26
+ message ||= 'No default scope. Create one with init or get your scope with Faulty[:scope_name]'
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # The base error for all errors raised during circuit runs
32
+ #
33
+ class CircuitError < FaultyError
34
+ attr_reader :circuit
35
+
36
+ def initialize(message, circuit)
37
+ message ||= %(circuit error for "#{circuit.name}")
38
+ @circuit = circuit
39
+
40
+ super(message)
41
+ end
42
+ end
43
+
44
+ # Raised when running a circuit that is already open
45
+ class OpenCircuitError < CircuitError; end
46
+
47
+ # Raised when an error occurred while running a circuit
48
+ #
49
+ # The `cause` will always be set and will be the internal error
50
+ #
51
+ # @see CircuitTrippedError For when the circuit is tripped
52
+ class CircuitFailureError < CircuitError; end
53
+
54
+ # Raised when an error occurred causing a circuit to close
55
+ #
56
+ # The `cause` will always be set and will be the internal error
57
+ class CircuitTrippedError < CircuitError; end
58
+
59
+ # Raised if calling get or error on a result without checking it
60
+ class UncheckedResultError < FaultyError; end
61
+
62
+ # Raised if getting the wrong result type.
63
+ #
64
+ # For example, calling get on an error result will raise this
65
+ class WrongResultError < FaultyError; end
66
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ # The namespace for Faulty events and event listeners
5
+ module Events
6
+ # All possible events that can be raised by Faulty
7
+ EVENTS = %i[
8
+ cache_failure
9
+ circuit_cache_hit
10
+ circuit_cache_miss
11
+ circuit_cache_write
12
+ circuit_closed
13
+ circuit_failure
14
+ circuit_opened
15
+ circuit_reopened
16
+ circuit_skipped
17
+ circuit_success
18
+ storage_failure
19
+ ].freeze
20
+ end
21
+ end
22
+
23
+ require 'faulty/events/callback_listener'
24
+ require 'faulty/events/notifier'
25
+ require 'faulty/events/log_listener'
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ module Events
5
+ # A simple listener implementation that uses callback blocks as handlers
6
+ #
7
+ # Each event in {EVENTS} has a method on this class that can be used
8
+ # to register a callback for that event.
9
+ #
10
+ # @example
11
+ # listener = CallbackListener.new
12
+ # listener.circuit_opened do |payload|
13
+ # logger.error(
14
+ # "Circuit #{payload[:circuit].name} opened: #{payload[:error].message}"
15
+ # )
16
+ # end
17
+ class CallbackListener
18
+ def initialize
19
+ @handlers = {}
20
+ yield self if block_given?
21
+ end
22
+
23
+ # @param (see ListenerInterface#handle)
24
+ # @return [void]
25
+ def handle(event, payload)
26
+ return unless EVENTS.include?(event)
27
+ return unless @handlers.key?(event)
28
+
29
+ @handlers[event].each do |handler|
30
+ handler.call(payload)
31
+ end
32
+ end
33
+
34
+ EVENTS.each do |event|
35
+ define_method(event) do |&block|
36
+ @handlers[event] ||= []
37
+ @handlers[event] << block
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ module Events
5
+ # The interface required to implement a event listener
6
+ #
7
+ # This is for documentation only and is not loaded
8
+ class ListenerInterface
9
+ # Handle an event raised by Faulty
10
+ #
11
+ # @param event [Symbol] The event name. Will be a member of {EVENTS}.
12
+ # @param payload [Hash] A hash with keys based on the event type
13
+ # @return [void]
14
+ def handle(event, payload)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ module Events
5
+ # A default listener that logs Faulty events
6
+ class LogListener
7
+ attr_reader :logger
8
+
9
+ # @param logger A logger similar to stdlib `Logger`. Uses the Rails logger
10
+ # by default if available, otherwise it creates a new `Logger` to
11
+ # stderr.
12
+ def initialize(logger = nil)
13
+ logger ||= defined?(Rails) ? Rails.logger : ::Logger.new($stderr)
14
+ @logger = logger
15
+ end
16
+
17
+ # (see ListenerInterface#handle)
18
+ def handle(event, payload)
19
+ return unless EVENTS.include?(event)
20
+
21
+ send(event, payload) if respond_to?(event, true)
22
+ end
23
+
24
+ private
25
+
26
+ def circuit_cache_hit(payload)
27
+ log(:debug, 'Circuit cache hit', payload[:circuit].name, key: payload[:key])
28
+ end
29
+
30
+ def circuit_cache_miss(payload)
31
+ log(:debug, 'Circuit cache miss', payload[:circuit].name, key: payload[:key])
32
+ end
33
+
34
+ def circuit_cache_write(payload)
35
+ log(:debug, 'Circuit cache write', payload[:circuit].name, key: payload[:key])
36
+ end
37
+
38
+ def circuit_success(payload)
39
+ log(:debug, 'Circuit succeeded', payload[:circuit].name, state: payload[:status].state)
40
+ end
41
+
42
+ def circuit_failure(payload)
43
+ log(
44
+ :error, 'Circuit failed', payload[:circuit].name,
45
+ state: payload[:status].state,
46
+ error: payload[:error].message
47
+ )
48
+ end
49
+
50
+ def circuit_skipped(payload)
51
+ log(:warn, 'Circuit skipped', payload[:circuit].name)
52
+ end
53
+
54
+ def circuit_opened(payload)
55
+ log(:error, 'Circuit opened', payload[:circuit].name, error: payload[:error].message)
56
+ end
57
+
58
+ def circuit_reopened(payload)
59
+ log(:error, 'Circuit reopened', payload[:circuit].name, error: payload[:error].message)
60
+ end
61
+
62
+ def circuit_closed(payload)
63
+ log(:info, 'Circuit closed', payload[:circuit].name)
64
+ end
65
+
66
+ def cache_failure(payload)
67
+ log(
68
+ :error, 'Cache failure', payload[:action],
69
+ key: payload[:key],
70
+ error: payload[:error].message
71
+ )
72
+ end
73
+
74
+ def storage_failure(payload)
75
+ log(
76
+ :error, 'Storage failure', payload[:action],
77
+ circuit: payload[:circuit]&.name,
78
+ error: payload[:error].message
79
+ )
80
+ end
81
+
82
+ def log(level, msg, action, extra = {})
83
+ extra_str = extra.map { |k, v| "#{k}=#{v}" }.join(' ')
84
+ logger.public_send(level, "#{msg}: #{action} #{extra_str}")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ module Events
5
+ # The default event dispatcher for Faulty
6
+ class Notifier
7
+ # @param listeners [Array<ListenerInterface>] An array of event listeners
8
+ def initialize(listeners = [])
9
+ @listeners = listeners.freeze
10
+ end
11
+
12
+ # Notify all listeners of an event
13
+ #
14
+ # @param event [Symbol] The event name
15
+ # @param payload [Hash] A hash of event payload data. The payload keys
16
+ # differ between events, but should be consistent across calls for a
17
+ # single event
18
+ def notify(event, payload)
19
+ raise ArgumentError, "Unknown event #{event}" unless EVENTS.include?(event)
20
+
21
+ @listeners.each { |l| l.handle(event, payload) }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ # A struct that cannot be modified after initialization
5
+ module ImmutableOptions
6
+ # @param hash [Hash] A hash of attributes to initialize with
7
+ # @yield [self] Yields itself to the block to set options before freezing
8
+ def initialize(hash)
9
+ defaults.merge(hash).each { |key, value| self[key] = value }
10
+ yield self if block_given?
11
+ finalize
12
+ required.each do |key|
13
+ raise ArgumentError, "Missing required attribute #{key}" if self[key].nil?
14
+ end
15
+ freeze
16
+ end
17
+
18
+ private
19
+
20
+ # A hash of default values to set before yielding to the block
21
+ #
22
+ # @return [Hash<Symbol, Object>]
23
+ def defaults
24
+ {}
25
+ end
26
+
27
+ # An array of required attributes
28
+ #
29
+ # @return [Array<Symbol>]
30
+ def required
31
+ []
32
+ end
33
+
34
+ # Runs before freezing to finalize attribute initialization
35
+ #
36
+ # @return [void]
37
+ def finalize
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faulty
4
+ # An approximation of the `Result` type from some strongly-typed languages.
5
+ #
6
+ # F#: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/results
7
+ #
8
+ # Rust: https://doc.rust-lang.org/std/result/enum.Result.html
9
+ #
10
+ # Since we can't enforce the type at compile-time, we use runtime errors to
11
+ # check the result for consistency as early as possible. This means we
12
+ # enforce runtime checks of the result type. This approach does not eliminate
13
+ # issues, but it does help remind the user to check the result in most cases.
14
+ #
15
+ # This is returned from {Circuit#try_run} to allow error handling without
16
+ # needing to rescue from errors.
17
+ #
18
+ # @example
19
+ # result = Result.new(ok: 'foo')
20
+ #
21
+ # # Check the result before calling get
22
+ # if result.ok?
23
+ # puts result.get
24
+ # else
25
+ # puts result.error.message
26
+ # end
27
+ #
28
+ # @example
29
+ # result = Result.new(error: StandardError.new)
30
+ # puts result.or_default('fallback') # prints "fallback"
31
+ #
32
+ # @example
33
+ # result = Result.new(ok: 'foo')
34
+ # result.get # raises UncheckedResultError
35
+ #
36
+ # @example
37
+ # result = Result.new(ok: 'foo')
38
+ # if result.ok?
39
+ # result.error.message # raises WrongResultError
40
+ # end
41
+ class Result
42
+ # The constant used to designate that a value is empty
43
+ #
44
+ # This is needed to differentiate between an ok `nil` value and
45
+ # an empty value.
46
+ #
47
+ # @private
48
+ NOTHING = {}.freeze
49
+
50
+ # Create a new `Result` with either an ok or error value
51
+ #
52
+ # Exactly one parameter must be given, and not both.
53
+ #
54
+ # @param ok An ok value
55
+ # @param error [Error] An error instance
56
+ def initialize(ok: NOTHING, error: NOTHING) # rubocop:disable Naming/MethodParameterName
57
+ if ok.equal?(NOTHING) && error.equal?(NOTHING)
58
+ raise ArgumentError, 'Result must have an ok or error value'
59
+ end
60
+ if !ok.equal?(NOTHING) && !error.equal?(NOTHING)
61
+ raise ArgumentError, 'Result must not have both an ok and error value'
62
+ end
63
+
64
+ @ok = ok
65
+ @error = error
66
+ @checked = false
67
+ end
68
+
69
+ # Check if the value is an ok value
70
+ #
71
+ # @return [Boolean] True if this result is ok
72
+ def ok?
73
+ @checked = true
74
+ ok_unchecked?
75
+ end
76
+
77
+ # Check if the value is an error value
78
+ #
79
+ # @return [Boolean] True if this result is an error
80
+ def error?
81
+ !ok?
82
+ end
83
+
84
+ # Get the ok value
85
+ #
86
+ # @raise UncheckedResultError if this result was not checked using {#ok?} or {#error?}
87
+ # @raise WrongResultError if this result is an error
88
+ # @return The ok value
89
+ def get
90
+ validate_checked!('get')
91
+ unsafe_get
92
+ end
93
+
94
+ # Get the ok value without checking whether it's safe to do so
95
+ #
96
+ # @raise WrongResultError if this result is an error
97
+ # @return The ok value
98
+ def unsafe_get
99
+ raise WrongResultError, 'Tried to get value for error result' unless ok_unchecked?
100
+
101
+ @ok
102
+ end
103
+
104
+ # Get the error value
105
+ #
106
+ # @raise UncheckedResultError if this result was not checked using {#ok?} or {#error?}
107
+ # @raise WrongResultError if this result is ok
108
+ def error
109
+ validate_checked!('error')
110
+ unsafe_error
111
+ end
112
+
113
+ # Get the error value without checking whether it's safe to do so
114
+ #
115
+ # @raise WrongResultError if this result is ok
116
+ # @return [Error] The error
117
+ def unsafe_error
118
+ raise WrongResultError, 'Tried to get error for ok result' if ok_unchecked?
119
+
120
+ @error
121
+ end
122
+
123
+ # Get the ok value if this result is ok, otherwise return a default
124
+ #
125
+ # @param default The default value. Ignored if a block is given
126
+ # @yield A block returning the default value
127
+ # @return The ok value or the default if this result is an error
128
+ def or_default(default = nil)
129
+ if ok_unchecked?
130
+ @ok
131
+ elsif block_given?
132
+ yield @error
133
+ else
134
+ default
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def ok_unchecked?
141
+ !@ok.equal?(NOTHING)
142
+ end
143
+
144
+ def validate_checked!(method)
145
+ unless @checked
146
+ raise UncheckedResultError, "Result: Called #{method} without checking ok? or error?"
147
+ end
148
+ end
149
+ end
150
+ end