faulty 0.2.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/faulty/error.rb CHANGED
@@ -28,11 +28,13 @@ class Faulty
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 @@ class 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 @@ class 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
@@ -72,11 +72,10 @@ class 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 = {})
@@ -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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Patch
5
+ # Can be included in patch modules to provide common functionality
6
+ #
7
+ # The patch needs to set `@faulty_circuit`
8
+ #
9
+ # @example
10
+ # module ThingPatch
11
+ # include Faulty::Patch::Base
12
+ #
13
+ # def initialize(options = {})
14
+ # @faulty_circuit = Faulty::Patch.circuit_from_hash('thing', options[:faulty])
15
+ # end
16
+ #
17
+ # def do_something
18
+ # faulty_run { super }
19
+ # end
20
+ # end
21
+ #
22
+ # Thing.prepend(ThingPatch)
23
+ module Base
24
+ # Run a block wrapped by `@faulty_circuit`
25
+ #
26
+ # If `@faulty_circuit` is not set, the block will be run with no
27
+ # circuit.
28
+ #
29
+ # Nested calls to this method will only cause the circuit to be triggered
30
+ # once.
31
+ #
32
+ # @yield A block to run inside the circuit
33
+ # @return The block return value
34
+ def faulty_run
35
+ faulty_running_key = "faulty_running_#{object_id}"
36
+ return yield unless @faulty_circuit
37
+ return yield if Thread.current[faulty_running_key]
38
+
39
+ Thread.current[faulty_running_key] = true
40
+ @faulty_circuit.run { yield }
41
+ ensure
42
+ Thread.current[faulty_running_key] = nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mysql2'
4
+
5
+ if Gem::Version.new(Mysql2::VERSION) < Gem::Version.new('0.5.0')
6
+ raise NotImplementedError, 'The faulty mysql2 patch requires mysql2 0.5.0 or later'
7
+ end
8
+
9
+ class Faulty
10
+ module Patch
11
+ # Patch Mysql2 to run connections and queries in a circuit
12
+ #
13
+ # This module is not required by default
14
+ #
15
+ # Pass a `:faulty` key into your MySQL connection options to enable
16
+ # circuit protection. See {Patch.circuit_from_hash} for the available
17
+ # options.
18
+ #
19
+ # COMMIT, ROLLBACK, and RELEASE SAVEPOINT queries are intentionally not
20
+ # protected by the circuit. This is to allow open transactions to be closed
21
+ # if possible.
22
+ #
23
+ # @example
24
+ # require 'faulty/patch/mysql2'
25
+ #
26
+ # mysql = Mysql2::Client.new(host: '127.0.0.1', faulty: {})
27
+ # mysql.query('SELECT * FROM users') # raises Faulty::CircuitError if connection fails
28
+ #
29
+ # # If the faulty key is not given, no circuit is used
30
+ # mysql = Mysql2::Client.new(host: '127.0.0.1')
31
+ # mysql.query('SELECT * FROM users') # not protected by a circuit
32
+ #
33
+ # @see Patch.circuit_from_hash
34
+ module Mysql2
35
+ include Base
36
+
37
+ Patch.define_circuit_errors(self, ::Mysql2::Error::ConnectionError)
38
+
39
+ QUERY_WHITELIST = [
40
+ %r{\A(?:/\*.*?\*/)?\s*ROLLBACK}i,
41
+ %r{\A(?:/\*.*?\*/)?\s*COMMIT}i,
42
+ %r{\A(?:/\*.*?\*/)?\s*RELEASE\s+SAVEPOINT}i
43
+ ].freeze
44
+
45
+ def initialize(opts = {})
46
+ @faulty_circuit = Patch.circuit_from_hash(
47
+ 'mysql2',
48
+ opts[:faulty],
49
+ errors: [
50
+ ::Mysql2::Error::ConnectionError,
51
+ ::Mysql2::Error::TimeoutError
52
+ ],
53
+ patched_error_module: Faulty::Patch::Mysql2
54
+ )
55
+
56
+ super
57
+ end
58
+
59
+ # Protect manual connection pings
60
+ def ping
61
+ faulty_run { super }
62
+ rescue Faulty::Patch::Mysql2::FaultyError
63
+ false
64
+ end
65
+
66
+ # Protect the initial connnection
67
+ def connect(*args)
68
+ faulty_run { super }
69
+ end
70
+
71
+ # Protect queries unless they are whitelisted
72
+ def query(*args)
73
+ return super if QUERY_WHITELIST.any? { |r| !r.match(args.first).nil? }
74
+
75
+ faulty_run { super }
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ ::Mysql2::Client.prepend(Faulty::Patch::Mysql2)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+
5
+ class Faulty
6
+ module Patch
7
+ # Patch Redis to run all network IO in a circuit
8
+ #
9
+ # This module is not required by default
10
+ #
11
+ # Pass a `:faulty` key into your MySQL connection options to enable
12
+ # circuit protection. See {Patch.circuit_from_hash} for the available
13
+ # options.
14
+ #
15
+ # @example
16
+ # require 'faulty/patch/redis'
17
+ #
18
+ # redis = Redis.new(url: 'redis://localhost:6379', faulty: {})
19
+ # redis.connect # raises Faulty::CircuitError if connection fails
20
+ #
21
+ # # If the faulty key is not given, no circuit is used
22
+ # redis = Redis.new(url: 'redis://localhost:6379')
23
+ # redis.connect # not protected by a circuit
24
+ #
25
+ # @see Patch.circuit_from_hash
26
+ module Redis
27
+ include Base
28
+
29
+ Patch.define_circuit_errors(self, ::Redis::BaseConnectionError)
30
+
31
+ class BusyError < ::Redis::CommandError
32
+ end
33
+
34
+ # Patches Redis to add the `:faulty` key
35
+ def initialize(options = {})
36
+ @faulty_circuit = Patch.circuit_from_hash(
37
+ 'redis',
38
+ options[:faulty],
39
+ errors: [
40
+ ::Redis::BaseConnectionError,
41
+ BusyError
42
+ ],
43
+ patched_error_module: Faulty::Patch::Redis
44
+ )
45
+
46
+ super
47
+ end
48
+
49
+ # The initial connection is protected by a circuit
50
+ def connect
51
+ faulty_run { super }
52
+ end
53
+
54
+ # Protect command calls
55
+ def call(command)
56
+ faulty_run { super }
57
+ end
58
+
59
+ # Protect command_loop calls
60
+ def call_loop(command, timeout = 0)
61
+ faulty_run { super }
62
+ end
63
+
64
+ # Protect pipelined commands
65
+ def call_pipelined(commands)
66
+ faulty_run { super }
67
+ end
68
+
69
+ # Inject specific error classes if client is patched
70
+ #
71
+ # This method does not raise errors, it returns them
72
+ # as exception objects, so we simply modify that error if necessary and
73
+ # return it.
74
+ #
75
+ # The call* methods above will then raise that error, so we are able to
76
+ # capture it with faulty_run.
77
+ def io(&block)
78
+ return super unless @faulty_circuit
79
+
80
+ reply = super
81
+ if reply.is_a?(::Redis::CommandError)
82
+ if reply.message.start_with?('BUSY')
83
+ reply = BusyError.new(reply.message)
84
+ end
85
+ end
86
+
87
+ reply
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ ::Redis::Client.prepend(Faulty::Patch::Redis)