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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shift
4
+ module CircuitBreaker
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ # -*- encoding: utf-8 -*-
3
+
4
+ $:.push File.expand_path("../lib", __FILE__)
5
+
6
+ require "shift/circuit_breaker/version"
7
+
8
+ Gem::Specification.new do |s|
9
+ s.name = "shift-circuit-breaker"
10
+ s.version = Shift::CircuitBreaker::VERSION
11
+ s.platform = Gem::Platform::RUBY
12
+ s.summary = "A generic implementation of the Circuit Breaker pattern in Ruby"
13
+ s.description = <<-DOC
14
+ The library provides a mechanism for detecting, monitoring and controlling external service calls that will
15
+ most-likely fail at some point (e.g. timeout) and cause request queuing, thus preventing cascading system failures.
16
+ DOC
17
+ s.homepage = "https://github.com/shiftcommerce/shift-circuit-breaker"
18
+ s.authors = ["Mufudzi Masaire"]
19
+ s.email = "mufudzi.masaire@shiftcommerce.com"
20
+ s.date = Time.now.strftime("%Y-%m-%d")
21
+ s.license = "MIT"
22
+
23
+ s.required_ruby_version = ">= 2.3.0"
24
+ s.require_paths = ["lib"]
25
+
26
+ s.files = `git ls-files`.split("\n")
27
+ s.test_files = `git ls-files -- { test, spec, features }/*`.split("\n")
28
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
29
+
30
+ s.add_runtime_dependency "activesupport", "~> 5.1", ">= 5.1.4"
31
+ s.add_runtime_dependency "newrelic_rpm", "~> 4.8", ">= 4.8.0.341"
32
+ s.add_runtime_dependency "sentry-raven", "~> 2.7", ">= 2.7.2"
33
+
34
+ s.add_development_dependency "bundler", "~> 1.16"
35
+ s.add_development_dependency "pry", "~> 0.11.3"
36
+ s.add_development_dependency "rake", "~> 12.3"
37
+ s.add_development_dependency "rspec", "~> 3.7"
38
+ s.add_development_dependency "rubocop", "~> 0.52.1"
39
+ s.add_development_dependency "timecop", "~> 0.9.1"
40
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Shift
6
+ module CircuitBreaker
7
+ module Adapters
8
+ describe BaseAdapter do
9
+ context "#call" do
10
+ it "raises NotImplementedError" do
11
+ # Arrange
12
+ message = "some message"
13
+
14
+ # Act & Assert
15
+ expect { described_class.call(message) }.to raise_error(NotImplementedError)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module NewRelic
6
+ module Agent
7
+ def self.increment_metric(*); end
8
+ end
9
+ end
10
+
11
+ module Shift
12
+ module CircuitBreaker
13
+ module Adapters
14
+ describe NewRelicAdapter do
15
+ it "is a subclass of 'Shift::CircuitBreaker::Adapters::BaseAdapter'" do
16
+ expect(described_class).to be < Shift::CircuitBreaker::Adapters::BaseAdapter
17
+ end
18
+
19
+ context "#call" do
20
+ it "does not raise NotImplementedError" do
21
+ # Arrange
22
+ metric = "Custom/TestCircuitBreaker/closed"
23
+
24
+ # Act & Assert
25
+ expect { described_class.call(metric) }.not_to raise_error(NotImplementedError)
26
+ end
27
+
28
+ it "calls NewRelic::Agent#increment_metric" do
29
+ # Arrange
30
+ metric = "Custom/TestCircuitBreaker/closed"
31
+
32
+ allow(::NewRelic::Agent).to receive(:increment_metric)
33
+
34
+ # Act
35
+ described_class.call(metric)
36
+
37
+ # Assert
38
+ expect(::NewRelic::Agent).to have_received(:increment_metric).with(metric).once
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Sentry
6
+ def self.capture_exception(*); end
7
+ end
8
+
9
+ module Shift
10
+ module CircuitBreaker
11
+ module Adapters
12
+ describe SentryAdapter do
13
+ it "is a subclass of 'Shift::CircuitBreaker::Adapters::BaseAdapter'" do
14
+ expect(described_class).to be < Shift::CircuitBreaker::Adapters::BaseAdapter
15
+ end
16
+
17
+ context "#call" do
18
+ it "does not raise NotImplementedError" do
19
+ # Arrange
20
+ message = "some message"
21
+
22
+ # Act & Assert
23
+ expect { described_class.call(message) }.not_to raise_error(NotImplementedError)
24
+ end
25
+
26
+ it "calls Sentry#capture_exception" do
27
+ # Arrange
28
+ message = "some exception"
29
+
30
+ allow(::Raven).to receive(:capture_exception)
31
+
32
+ # Act
33
+ described_class.call(message)
34
+
35
+ # Assert
36
+ expect(::Raven).to have_received(:capture_exception).with(message).once
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Shift
6
+ module CircuitBreaker
7
+ describe CircuitHandler do
8
+ before do
9
+ allow($stdout).to receive(:write)
10
+ end
11
+
12
+ context "Exception Handling" do
13
+ let(:default_error_threshold) { 10 }
14
+ let(:default_skip_duration) { 60 }
15
+
16
+ context "when a timeout exception is raised" do
17
+ it "returns the fallback" do
18
+ # Arrange
19
+ operation_stub = instance_double("Operation")
20
+ fallback_stub = instance_double("Fallback")
21
+
22
+ allow(operation_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
23
+
24
+ # Act
25
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
26
+ operation_result = cb.call(operation: -> { operation_stub.perform_task }, fallback: -> { fallback_stub })
27
+
28
+ # Assert
29
+ expect(operation_result).to eq(fallback_stub)
30
+ end
31
+ end
32
+
33
+ context "when the error_threshold is exceeded" do
34
+ it "opens the curcuit returns the fallback" do
35
+ # Arrange
36
+ operation_1_stub = instance_double("Operation1")
37
+ operation_2_stub = instance_double("Operation2")
38
+ operation_3_stub = instance_double("Operation3")
39
+ fallback_stub = instance_double("Fallback")
40
+
41
+ allow(operation_1_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
42
+ allow(operation_2_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
43
+ allow(operation_3_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
44
+
45
+ # Act & Assert
46
+ cb = described_class.new(:test_circuit_breaker, error_threshold: 2, skip_duration: default_skip_duration)
47
+
48
+ # The first operation will fail with Timeout::Error, resulting in the exception being caught and
49
+ # the fallback being returned as the operation result. The error_count is incremented to 1.
50
+ aggregate_failures do
51
+ expect(operation_1_stub).to receive(:perform_task)
52
+ operation_1_result = cb.call(operation: -> { operation_1_stub.perform_task }, fallback: -> { fallback_stub })
53
+ # Check Circuit Breaker state and result
54
+ expect(cb.state).to eq(:closed)
55
+ expect(cb.error_count).to eq(1)
56
+ expect(operation_1_result).to eq(fallback_stub)
57
+ end
58
+
59
+ # The second operation will also fail with Timeout::Error, resulting in the exception being caught and
60
+ # the fallback being returned as the operation result. The error_count is incremented to 2 and the circuit
61
+ # is opened.
62
+ aggregate_failures do
63
+ expect(operation_2_stub).to receive(:perform_task)
64
+ operation_2_result = cb.call(operation: -> { operation_2_stub.perform_task }, fallback: -> { fallback_stub })
65
+ # Check Circuit Breaker state and result
66
+ expect(cb.state).to eq(:open)
67
+ expect(cb.error_count).to eq(2)
68
+ expect(operation_2_result).to eq(fallback_stub)
69
+ end
70
+
71
+ # The third operation will not be run as the circuit is open. This should result in #call not being executed
72
+ # and the fallback being returned early.
73
+ aggregate_failures do
74
+ expect(operation_3_stub).not_to receive(:perform_task)
75
+ operation_3_result = cb.call(operation: -> { operation_3_stub.perform_task }, fallback: -> { fallback_stub })
76
+ # Check Circuit Breaker state and result
77
+ expect(cb.state).to eq(:open)
78
+ expect(cb.error_count).to eq(2)
79
+ expect(operation_3_result).to eq(fallback_stub)
80
+ end
81
+ end
82
+ end
83
+
84
+ context "when the error_threshold is exceeded and skip_duration has expired" do
85
+ after { Timecop.return }
86
+
87
+ it "closes the circuit and returns the operation result" do
88
+ # Arrange
89
+ operation_1_stub = instance_double("Operation1")
90
+ operation_2_stub = instance_double("Operation2")
91
+ operation_3_stub = instance_double("Operation3")
92
+ fallback_stub = instance_double("Fallback")
93
+ expected_result_stub = instance_double("ExpectedResult")
94
+
95
+ allow(operation_1_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
96
+ allow(operation_2_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
97
+ allow(operation_3_stub).to receive(:perform_task).and_return(expected_result_stub)
98
+
99
+ # Act & Assert
100
+ cb = described_class.new(:test_circuit_breaker, error_threshold: 2, skip_duration: 10)
101
+
102
+ # The first operation will fail with Timeout::Error, resulting in the exception being caught and
103
+ # the fallback being returned as the operation result. The error_count is incremented to 1.
104
+ aggregate_failures do
105
+ expect(operation_1_stub).to receive(:perform_task)
106
+ operation_1_result = cb.call(operation: -> { operation_1_stub.perform_task }, fallback: -> { fallback_stub })
107
+ # Check Circuit Breaker state and result
108
+ expect(cb.state).to eq(:closed)
109
+ expect(cb.error_count).to eq(1)
110
+ expect(operation_1_result).to eq(fallback_stub)
111
+ end
112
+
113
+ # The second operation will also fail with Timeout::Error, resulting in the exception being caught and
114
+ # the fallback being returned as the operation result. The error_count is incremented to 2 and the circuit
115
+ # is opened.
116
+ aggregate_failures do
117
+ expect(operation_2_stub).to receive(:perform_task)
118
+ operation_2_result = cb.call(operation: -> { operation_2_stub.perform_task }, fallback: -> { fallback_stub })
119
+ # Check Circuit Breaker state and result
120
+ expect(cb.state).to eq(:open)
121
+ expect(cb.error_count).to eq(2)
122
+ expect(operation_2_result).to eq(fallback_stub)
123
+ end
124
+
125
+ # The third request is fired after the skip duration has expired. It should the allowed to execute,
126
+ # ie. #call should be executed, and the expected operation result returned.
127
+ Timecop.travel(Time.now + 10.seconds) do
128
+ aggregate_failures do
129
+ expect(operation_3_stub).to receive(:perform_task)
130
+ operation_3_result = cb.call(operation: -> { operation_3_stub.perform_task }, fallback: -> { fallback_stub })
131
+
132
+ # Check Circuit Breaker state and result
133
+ expect(cb.state).to eq(:closed)
134
+ expect(operation_3_result).to eq(expected_result_stub)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Shift
6
+ module CircuitBreaker
7
+ describe CircuitHandler do
8
+ before do
9
+ allow($stdout).to receive(:write)
10
+ end
11
+
12
+ context "Monitoring" do
13
+ let(:default_error_threshold) { 10 }
14
+ let(:default_skip_duration) { 60 }
15
+
16
+ context "when an operation is successful" do
17
+ it "records the metric" do
18
+ # Arrange
19
+ monitor_stub = instance_double("Shift::CircuitMonitor")
20
+ operation_stub = instance_double("Operation")
21
+ fallback_stub = instance_double("Fallback")
22
+ expected_result_stub = instance_double("ExpectedResult")
23
+
24
+ allow(monitor_stub).to receive(:record_metric)
25
+ allow(operation_stub).to receive(:perform_task).and_return(expected_result_stub)
26
+
27
+ # Act
28
+ cb = described_class.new(:test_circuit_breaker,
29
+ error_threshold: default_skip_duration,
30
+ skip_duration: default_skip_duration,
31
+ monitor: monitor_stub)
32
+
33
+ # Assert
34
+ aggregate_failures do
35
+ expect(operation_stub).to receive(:perform_task).and_return(expected_result_stub)
36
+
37
+ operation_result = cb.call(operation: -> { operation_stub.perform_task }, fallback: -> { fallback_stub })
38
+
39
+ expect(monitor_stub).to have_received(:record_metric)
40
+ expect(operation_result).to eq(expected_result_stub)
41
+ end
42
+ end
43
+ end
44
+
45
+ context "when an exception is raised" do
46
+ it "records the metric" do
47
+ # Arrange
48
+ monitor_stub = instance_double("Shift::CircuitMonitor")
49
+ operation_stub = instance_double("Operation")
50
+ fallback_stub = instance_double("Fallback")
51
+
52
+ allow(monitor_stub).to receive(:record_metric)
53
+ allow(operation_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
54
+
55
+ # Act
56
+ cb = described_class.new(:test_circuit_breaker, error_threshold: 1, skip_duration: default_skip_duration, monitor: monitor_stub)
57
+
58
+ # Assert
59
+ aggregate_failures do
60
+ expect(operation_stub).to receive(:perform_task).and_raise(Timeout::Error, "Request Timeout")
61
+
62
+ operation_result = cb.call(operation: -> { operation_stub.perform_task }, fallback: -> { fallback_stub })
63
+
64
+ expect(monitor_stub).to have_received(:record_metric)
65
+ expect(operation_result).to eq(fallback_stub)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Shift
6
+ module CircuitBreaker
7
+ describe CircuitHandler do
8
+ before do
9
+ allow($stdout).to receive(:write)
10
+ end
11
+
12
+ context "when given a valid operation" do
13
+ let(:default_error_threshold) { 10 }
14
+ let(:default_skip_duration) { 60 }
15
+
16
+ it "returns the expected result" do
17
+ # Arrange
18
+ operation_stub = instance_double("Operation")
19
+ fallback_stub = instance_double("Fallback")
20
+ expected_result_stub = instance_double("ExpectedResult")
21
+
22
+ allow(operation_stub).to receive(:perform_task).and_return(expected_result_stub)
23
+
24
+ # Act
25
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
26
+ operation_result = cb.call(operation: -> { operation_stub.perform_task }, fallback: -> { fallback_stub })
27
+
28
+ # Assert
29
+ expect(operation_result).to eq(expected_result_stub)
30
+ end
31
+ end
32
+
33
+ context "when given additional exception classes" do
34
+ let(:default_error_threshold) { 10 }
35
+ let(:default_skip_duration) { 60 }
36
+
37
+ it "rescues the exception and returns the fallback" do
38
+ # Arrange
39
+ operation_stub = instance_double("Operation")
40
+ fallback_stub = instance_double("Fallback")
41
+ additional_exception_classes = [Faraday::ClientError]
42
+
43
+ allow(operation_stub).to receive(:perform_task).and_raise(Faraday::ClientError, "client error")
44
+
45
+ # Act
46
+ cb = described_class.new(:test_circuit_breaker,
47
+ error_threshold: default_error_threshold,
48
+ skip_duration: default_skip_duration,
49
+ additional_exception_classes: additional_exception_classes)
50
+
51
+ operation_result = cb.call(operation: -> { operation_stub.perform_task }, fallback: -> { fallback_stub })
52
+
53
+ # Assert
54
+ expect(operation_result).to eq(fallback_stub)
55
+ end
56
+ end
57
+
58
+ context "Invalid Arguments" do
59
+ context "when initialising service" do
60
+ let(:default_error_threshold) { 10 }
61
+ let(:default_skip_duration) { 60 }
62
+
63
+ context "when no error_threshold is provided" do
64
+ it "raises an ArgumentError" do
65
+ # Act & Assert
66
+ expect { described_class.new(:test_circuit_breaker, skip_duration: default_skip_duration) }.to raise_error(ArgumentError)
67
+ end
68
+ end
69
+
70
+ context "when no skip_duration is provided" do
71
+ it "raises an ArgumentError" do
72
+ # Act & Assert
73
+ expect { described_class.new(:test_circuit_breaker, error_threshold: default_skip_duration) }.to raise_error(ArgumentError)
74
+ end
75
+ end
76
+ end
77
+
78
+ context "Invalid #call arguments" do
79
+ let(:default_error_threshold) { 10 }
80
+ let(:default_skip_duration) { 60 }
81
+
82
+ context "when no operation is given" do
83
+ it "raises an ArgumentError" do
84
+ # Arrange
85
+ fallback_stub = instance_double("Fallback")
86
+
87
+ # Act
88
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
89
+
90
+ # Assert
91
+ expect { cb.call(fallback: -> { fallback_stub }) }.to raise_error(ArgumentError)
92
+ end
93
+ end
94
+
95
+ context "when no fallback is given" do
96
+ it "raises an ArgumentError" do
97
+ # Arrange
98
+ operation_stub = instance_double("Operation")
99
+
100
+ # Act
101
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
102
+
103
+ # Act & Assert
104
+ expect { cb.call(operation: -> { operation_stub }) }.to raise_error(ArgumentError)
105
+ end
106
+ end
107
+
108
+ context "when given nil as the operation" do
109
+ it "raises an ArgumentError" do
110
+ # Arrange
111
+ fallback_stub = instance_double("Fallback")
112
+
113
+ # Act
114
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
115
+
116
+ # Assert
117
+ expect { cb.call(operation: nil, fallback: -> { fallback_stub }) }.to raise_error(ArgumentError)
118
+ end
119
+ end
120
+
121
+ context "when given nil as the fallback" do
122
+ it "raises an ArgumentError" do
123
+ # Arrange
124
+ operation_stub = instance_double("Operation")
125
+
126
+ # Act
127
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
128
+
129
+ # Assert
130
+ expect { cb.call(operation: -> { operation_stub }, fallback: nil) }.to raise_error(ArgumentError)
131
+ end
132
+ end
133
+
134
+ context "when given an operation that does not implement #call" do
135
+ it "raises an ArgumentError" do
136
+ # Arrange
137
+ operation_stub = instance_double("Operation")
138
+ fallback_stub = instance_double("Fallback")
139
+
140
+ # Act
141
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
142
+
143
+ # Assert
144
+ expect { cb.call(operation: operation_stub, fallback: -> { fallback_stub }) }.to raise_error(ArgumentError)
145
+ end
146
+ end
147
+
148
+ context "when given a fallback that does not implement #call" do
149
+ it "raises an ArgumentError" do
150
+ # Arrange
151
+ operation_stub = instance_double("Operation")
152
+ fallback_stub = instance_double("Fallback")
153
+
154
+ # Act
155
+ cb = described_class.new(:test_circuit_breaker, error_threshold: default_error_threshold, skip_duration: default_skip_duration)
156
+
157
+ # Assert
158
+ expect { cb.call(operation: -> { operation_stub }, fallback: fallback_stub) }.to raise_error(ArgumentError)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end