shift-circuit-breaker 0.2.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 +7 -0
  2. data/.codeclimate.yml +11 -0
  3. data/.gitignore +13 -0
  4. data/.reek +21 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +21 -0
  7. data/CONTRIBUTING.md +80 -0
  8. data/Gemfile +2 -0
  9. data/LICENSE +21 -0
  10. data/README.md +81 -0
  11. data/Rakefile +9 -0
  12. data/bin/console +7 -0
  13. data/bin/setup +7 -0
  14. data/config/newrelic.yml +28 -0
  15. data/config/rubocop/.layout_rubocop.yml +2 -0
  16. data/config/rubocop/.lint_rubocop.yml +47 -0
  17. data/config/rubocop/.metrics_rubocop.yml +39 -0
  18. data/config/rubocop/.naming_rubocop.yml +11 -0
  19. data/config/rubocop/.performance_rubocop.yml +60 -0
  20. data/config/rubocop/.style_rubocop.yml +154 -0
  21. data/lib/shift/circuit_breaker.rb +57 -0
  22. data/lib/shift/circuit_breaker/adapters/base_adapter.rb +13 -0
  23. data/lib/shift/circuit_breaker/adapters/newrelic_adapter.rb +13 -0
  24. data/lib/shift/circuit_breaker/adapters/sentry_adapter.rb +13 -0
  25. data/lib/shift/circuit_breaker/circuit_handler.rb +107 -0
  26. data/lib/shift/circuit_breaker/circuit_logger.rb +36 -0
  27. data/lib/shift/circuit_breaker/circuit_monitor.rb +32 -0
  28. data/lib/shift/circuit_breaker/config.rb +48 -0
  29. data/lib/shift/circuit_breaker/version.rb +7 -0
  30. data/shift-circuit-breaker.gemspec +40 -0
  31. data/spec/shift/adapters/base_adapter_spec.rb +21 -0
  32. data/spec/shift/adapters/newrelic_adapter_spec.rb +44 -0
  33. data/spec/shift/adapters/sentry_adapter_spec.rb +42 -0
  34. data/spec/shift/circuit_breaker/circuit_handler_exception_handling_spec.rb +142 -0
  35. data/spec/shift/circuit_breaker/circuit_handler_monitoring_spec.rb +72 -0
  36. data/spec/shift/circuit_breaker/circuit_handler_spec.rb +165 -0
  37. data/spec/shift/circuit_breaker/circuit_logger_spec.rb +67 -0
  38. data/spec/shift/circuit_breaker/circuit_monitor_spec.rb +52 -0
  39. data/spec/shift/circuit_breaker_spec.rb +73 -0
  40. data/spec/spec_helper.rb +25 -0
  41. metadata +230 -0
@@ -0,0 +1,154 @@
1
+ Style/Alias:
2
+ Enabled: false
3
+
4
+ Style/ArrayJoin:
5
+ Enabled: false
6
+
7
+ Style/AsciiComments:
8
+ Enabled: false
9
+
10
+ Style/Attr:
11
+ Enabled: false
12
+
13
+ Style/BlockComments:
14
+ Enabled: false
15
+
16
+ Style/CaseEquality:
17
+ Enabled: false
18
+
19
+ Style/CharacterLiteral:
20
+ Enabled: false
21
+
22
+ Style/ClassAndModuleChildren:
23
+ Enabled: false
24
+
25
+ Style/ClassVars:
26
+ Enabled: false
27
+
28
+ Style/CollectionMethods:
29
+ Enabled: true
30
+ PreferredMethods:
31
+ length: "size"
32
+
33
+ Style/ColonMethodCall:
34
+ Enabled: false
35
+
36
+ Style/CommentAnnotation:
37
+ Enabled: false
38
+
39
+ Style/Documentation:
40
+ Enabled: false
41
+
42
+ Style/DoubleNegation:
43
+ Enabled: false
44
+
45
+ Style/EachWithObject:
46
+ Enabled: false
47
+
48
+ Style/EmptyLiteral:
49
+ Enabled: false
50
+
51
+ Style/EvenOdd:
52
+ Enabled: false
53
+
54
+ Style/FlipFlop:
55
+ Enabled: false
56
+
57
+ Style/FormatString:
58
+ Enabled: false
59
+
60
+ Style/GlobalVars:
61
+ Enabled: false
62
+
63
+ Style/GuardClause:
64
+ Enabled: false
65
+
66
+ Style/IfUnlessModifier:
67
+ Enabled: false
68
+
69
+ Style/IfWithSemicolon:
70
+ Enabled: false
71
+
72
+ Style/Lambda:
73
+ Enabled: false
74
+
75
+ Style/LambdaCall:
76
+ Enabled: false
77
+
78
+ Style/LineEndConcatenation:
79
+ Enabled: false
80
+
81
+ Style/ModuleFunction:
82
+ Enabled: false
83
+
84
+ Style/NegatedIf:
85
+ Enabled: false
86
+
87
+ Style/NegatedWhile:
88
+ Enabled: false
89
+
90
+ Style/Next:
91
+ Enabled: false
92
+
93
+ Style/NilComparison:
94
+ Enabled: false
95
+
96
+ Style/Not:
97
+ Enabled: false
98
+
99
+ Style/NumericLiterals:
100
+ Enabled: false
101
+
102
+ Style/OneLineConditional:
103
+ Enabled: false
104
+
105
+ Style/PercentLiteralDelimiters:
106
+ Enabled: false
107
+
108
+ Style/PerlBackrefs:
109
+ Enabled: false
110
+
111
+ Style/Proc:
112
+ Enabled: false
113
+
114
+ Style/RaiseArgs:
115
+ Enabled: false
116
+
117
+ Style/RegexpLiteral:
118
+ Enabled: false
119
+
120
+ Style/SelfAssignment:
121
+ Enabled: false
122
+
123
+ Style/SignalException:
124
+ Enabled: false
125
+
126
+ Style/SingleLineMethods:
127
+ Enabled: false
128
+
129
+ Style/SpecialGlobalVars:
130
+ Enabled: false
131
+
132
+ Style/StringLiterals:
133
+ EnforcedStyle: double_quotes
134
+
135
+ Style/TrailingCommaInArguments:
136
+ Enabled: false
137
+
138
+ Style/TrailingCommaInLiteral:
139
+ Enabled: false
140
+
141
+ Style/TrivialAccessors:
142
+ Enabled: false
143
+
144
+ Style/VariableInterpolation:
145
+ Enabled: false
146
+
147
+ Style/WhenThen:
148
+ Enabled: false
149
+
150
+ Style/WhileUntilModifier:
151
+ Enabled: false
152
+
153
+ Style/WordArray:
154
+ Enabled: false
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/string/inflections"
6
+ require "faraday"
7
+ require "logger"
8
+ require "net/protocol"
9
+ require "sentry-raven"
10
+ require "singleton"
11
+ require "timeout"
12
+
13
+ require "shift/circuit_breaker/adapters/base_adapter"
14
+ require "shift/circuit_breaker/adapters/sentry_adapter"
15
+ require "shift/circuit_breaker/adapters/newrelic_adapter"
16
+ require "shift/circuit_breaker/config"
17
+ require "shift/circuit_breaker/circuit_logger"
18
+ require "shift/circuit_breaker/circuit_monitor"
19
+ require "shift/circuit_breaker/circuit_handler"
20
+ require "shift/circuit_breaker/version"
21
+
22
+ module Shift
23
+ #
24
+ # ==== Example Usage:
25
+ #
26
+ # class MyClass
27
+ # CIRCUIT_BREAKER = Shift::CircuitBreaker.new(:an_identifier_for_the_circuit,
28
+ # error_threshold: 10,
29
+ # skip_duration: 60,
30
+ # additional_exception_classes: [Faraday::ClientError]
31
+ # )
32
+ #
33
+ # def do_something
34
+ # # Note: operation and fallback should implement the public method #call or wrapped in a Proc/Lambda (as in the example below).
35
+ # CIRCUIT_BREAKER.call(operation: -> { SomeService.new(name: 'test').perform_task }, fallback: -> { [1, 2, 3, 4, 5].sum })
36
+ # end
37
+ # end
38
+ #
39
+ module CircuitBreaker
40
+ class << self
41
+ def new(*args)
42
+ Shift::CircuitBreaker::CircuitHandler.new(*args)
43
+ end
44
+
45
+ def config
46
+ @config ||= Shift::CircuitBreaker::Config.instance
47
+ end
48
+
49
+ def configure
50
+ @config = Shift::CircuitBreaker::Config.instance.tap do |config|
51
+ yield config if block_given?
52
+ end
53
+ @config.initialize_dependencies
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ module Adapters
6
+ class BaseAdapter
7
+ def self.call(_message)
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ module Adapters
6
+ class NewRelicAdapter < BaseAdapter
7
+ def self.call(message)
8
+ ::NewRelic::Agent.increment_metric(message) if defined?(NewRelic)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ module Adapters
6
+ class SentryAdapter < BaseAdapter
7
+ def self.call(message)
8
+ ::Raven.capture_exception(message) if defined?(Raven)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ #
6
+ # === Overview
7
+ #
8
+ # Implements a generic mechanism for detecting external service call timeouts and reduces the
9
+ # time spent waiting for further requests that will most-likely fail (e.g. timeout) and cause request queueing.
10
+ #
11
+ # Similar to a conventional circuit breaker, when a circuit is closed it allows operations
12
+ # to flow through. When the error_threshold is exceeded (tripped), the circuit is then opened for
13
+ # the defined skip_duration, ie. no operations are executed and the provided fallback is called.
14
+ #
15
+ class CircuitHandler
16
+ attr_accessor :name, :error_threshold, :skip_duration, :exception_classes, :error_count, :last_error_time, :state, :logger, :monitor
17
+
18
+ DEFAULT_EXCEPTION_CLASSES = [Net::OpenTimeout, Net::ReadTimeout, Faraday::TimeoutError, Timeout::Error].freeze
19
+
20
+ # Initializer creates an instance of the service
21
+ #
22
+ # @param [Symbol] name - the name used to identify the circuit breaker
23
+ # @param [Integer] error_threshold - The minimum error threshold required for the circuit to be opened/tripped
24
+ # @param [Integer] skip_duration - The duration in seconds the circuit should be open for before operations are allowed through/executed
25
+ # @param [Array] additional_exception_classes - Any additional exception classes to rescue along the DEFAULT_EXCEPTION_CLASSES
26
+ # @param [Object] logger - service to handle error logging
27
+ # @param [Object] monitor - service to monitor metric
28
+ def initialize(name,
29
+ error_threshold:,
30
+ skip_duration:,
31
+ additional_exception_classes: [],
32
+ logger: Shift::CircuitBreaker::CircuitLogger.new,
33
+ monitor: Shift::CircuitBreaker::CircuitMonitor.new)
34
+
35
+ self.name = name
36
+ self.error_threshold = error_threshold
37
+ self.skip_duration = skip_duration
38
+ self.exception_classes = (additional_exception_classes | DEFAULT_EXCEPTION_CLASSES)
39
+ self.logger = logger
40
+ self.monitor = monitor
41
+ self.error_count = 0
42
+ self.last_error_time = nil
43
+ self.state = :closed
44
+ end
45
+
46
+ # Performs the given operation within the circuit
47
+ # @param [Proc] operation - the operation to be performed
48
+ # @param [Proc] fallback - The result returned if the operation is not performed or raises an exception
49
+ def call(operation:, fallback:)
50
+ raise ArgumentError unless operation.respond_to?(:call) && fallback.respond_to?(:call)
51
+ set_state
52
+ if state == :open
53
+ monitor.record_metric(name, state)
54
+ return fallback.call
55
+ end
56
+ perform_operation(operation, fallback)
57
+ end
58
+
59
+ private
60
+
61
+ def set_state
62
+ # The curcuit is opened/tripped if the error_threshold is met or exceeded
63
+ # (error_count >= error_threshold) and the last_error_time is within
64
+ # the skip_duration (see comments in #skip_duration_expired?).
65
+ self.state = (error_count >= error_threshold) && !skip_duration_expired? ? :open : :closed
66
+ end
67
+
68
+ def skip_duration_expired?
69
+ return true unless last_error_time.present?
70
+ # IF the difference in time between now and the last_error_time
71
+ # is greater than the skip_duration, then it will have expired.
72
+ (Time.now - last_error_time) > skip_duration
73
+ end
74
+
75
+ def reset_state
76
+ # Reset the error attributes to default values.
77
+ self.error_count = 0
78
+ self.last_error_time = nil
79
+ self.state = :closed
80
+ end
81
+
82
+ def record_error
83
+ # Increment the error_count
84
+ self.error_count += 1
85
+ # Set the time the error occured.
86
+ self.last_error_time = Time.now
87
+ end
88
+
89
+ def perform_operation(operation, fallback)
90
+ response = operation.call
91
+ reset_state
92
+ monitor.record_metric(name, state)
93
+ response
94
+ rescue *exception_classes
95
+ handle_exception($!, fallback)
96
+ end
97
+
98
+ def handle_exception(exception, fallback)
99
+ record_error
100
+ set_state
101
+ monitor.record_metric(name, state)
102
+ logger.error(circuit_name: name, state: state, error_message: exception.message)
103
+ fallback.call
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ class CircuitLogger
6
+ attr_accessor :logger, :remote_logger
7
+
8
+ delegate :debug, :fatal, :info, :warn, :add, :log, to: :logger
9
+
10
+ ERROR_MESSAGE = <<~EOF
11
+ ====================================================================================
12
+ CIRCUIT BREAKER REQUEST FAILURE: %<circuit_name>s
13
+
14
+ STATE: %<state>s
15
+ MESSAGE: %<error_message>s
16
+ ====================================================================================
17
+ EOF
18
+
19
+ # Initializer creates an instance of the service
20
+ #
21
+ # @param [Object] logger - service to handle internal logging
22
+ # @param [Object] remote_logger - external error logging service eg. Sentry
23
+ def initialize(logger: ::Logger.new(STDOUT), remote_logger: Shift::CircuitBreaker::Adapters::SentryAdapter)
24
+ self.logger = logger
25
+ self.remote_logger = remote_logger
26
+ end
27
+
28
+ # @param [Object] context - contains :circuit_name, :state, :error_message
29
+ def error(context)
30
+ message = (ERROR_MESSAGE % context)
31
+ logger.error(message)
32
+ remote_logger.call(message) if remote_logger.respond_to?(:call)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ class CircuitMonitor
6
+ attr_accessor :monitor, :logger
7
+
8
+ # Initializer creates an instance of the service
9
+ #
10
+ # @param [Object] monitor - service to handle monitoring
11
+ # @param [Object] logger - service to handle logging
12
+ def initialize(monitor: Shift::CircuitBreaker::Adapters::NewRelicAdapter, logger: Shift::CircuitBreaker::CircuitLogger.new)
13
+ self.monitor = monitor
14
+ self.logger = logger
15
+ end
16
+
17
+ # @param [String] name - The circuit name
18
+ # @param [Symbol] state - The circuit state
19
+ def record_metric(name, state)
20
+ metric = formatted_metric(name, state)
21
+ monitor.call(metric) if monitor.respond_to?(:call)
22
+ logger.info("* #{metric} *")
23
+ end
24
+
25
+ private
26
+
27
+ def formatted_metric(name, state)
28
+ "Custom/#{name.to_s.classify}CircuitBreaker/#{state.to_s.classify}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ #
6
+ # Global Configuration Object
7
+ #
8
+ # ==== Example Usage:
9
+ #
10
+ # Add an initializer in your application (eg. shift_circuit_breaker.rb)
11
+ # with the following configs:
12
+ #
13
+ # Shift::CircuitBreaker.configure do |config|
14
+ # config.new_relic_license_key = ENV["NEW_RELIC_LICENSE_KEY"]
15
+ # config.new_relic_app_name = ENV["NEW_RELIC_APP_NAME"]
16
+ # config.sentry_dsn = ENV["SENTRY_DSN"]
17
+ # config.sentry_environments = %w[ production ]
18
+ # end
19
+ #
20
+ class Config
21
+ include Singleton
22
+
23
+ attr_accessor :new_relic_license_key, :new_relic_app_name, :sentry_dsn, :sentry_environments
24
+
25
+ def initialize_dependencies
26
+ initialize_sentry
27
+ initialize_newrelic
28
+ end
29
+
30
+ private
31
+
32
+ def initialize_sentry
33
+ if sentry_dsn
34
+ Raven.configure do |config|
35
+ config.dsn = sentry_dsn
36
+ config.environments = sentry_environments if sentry_environments.present?
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize_newrelic
42
+ if new_relic_app_name.present? && new_relic_license_key.present?
43
+ require "newrelic_rpm"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end