simple_circuit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9d409438d122cfbfc81797059de659d2ad6a1d6a3f637d1cefa6cc23b44e2af5
4
+ data.tar.gz: 6b5b02f2b429b7f28fc4c622138b20cdab1f76e5e5a433c9595d228fc4b3cd38
5
+ SHA512:
6
+ metadata.gz: a97e9e8635d643f2bbb41016601dd68e57ffaa9bb9ceb4def2412250fcafdfc1cab021dcb444e9d50ef6f7c521ca6586523dcd02c2dd7cf4681b24febb9f63e6
7
+ data.tar.gz: eeac0d15c1a5206d0392515e97d20aab7c1d7febb9a3106bde7343e29aa955652186b5929bea0574f9021af1db05a9103d118d5dca4370a13ac635751da68a00
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ before_install: gem install bundler -v 1.15.4
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in circuit.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Ilya Vassilevsky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # SimpleCircuit
2
+
3
+ A simple implementation of [Circuit Breaker](https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern) pattern.
4
+
5
+ Use it when you make calls to an unreliable service. It will not make the service reliable, but it will **fail fast** when the service is down, to prevent overload in your app.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'simple_circuit' # Circuit breaker to fail fast on external service outages
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install simple_circuit
22
+
23
+ ## Usage
24
+
25
+ Suppose you have calls to unreliable services like these:
26
+
27
+ ```ruby
28
+ client = UnreliableServiceClient.new(url: "https://api.example.io")
29
+ client.get_some_info # => "foo bar"
30
+ ```
31
+
32
+ When they go down or unresponsive, your app starts to slow down too. Queues filling up, etc.
33
+
34
+ If you'd rather have the calls fail fast, and handle failures fast, use it through a circuit:
35
+
36
+ ```ruby
37
+ client = UnreliableServiceClient.new(url: "https://api.example.io")
38
+ circuit = SimpleCircuit.new(payload: client)
39
+ circuit.pass(:get_some_info) # => "foo bar"
40
+ ```
41
+
42
+ You're passing the same message (`get_some_info`) to `client` object, but now it goes through a circuit.
43
+ It works exactly the same while the circuit is closed (there are no problems in the payload).
44
+
45
+ Interesting things begin when `client` starts throwing errors.
46
+
47
+ The first few errors (100 by default) are returned as is:
48
+
49
+ ```ruby
50
+ circuit.pass(:get_some_info) # => HTTP::TimeoutError
51
+ ```
52
+
53
+ This is still slow because it's the `client` object still working as usual.
54
+
55
+ But after 100 errors, the circuit **breaks**.
56
+ The payload is disconnected from the circuit.
57
+ It no longer receives the `get_some_info` message.
58
+ Instead, the circuit itself immediately throws the error.
59
+ So, each call will fail fast.
60
+ This will prevent overload in your app while the service is down.
61
+ This will also reduce the load on the service and hopefully allow it to recover faster.
62
+
63
+ The circuit will keep trying to connect the payload back and send the message through it, at regular intervals (by default, every minute).
64
+ When it succeeds, it will become closed again and will rely _all_ messages to the payload, just like in the beginning.
65
+
66
+ ### Customization
67
+
68
+ You can customize several parameters of circuits. The defaults are show below:
69
+
70
+ ```ruby
71
+ circuit = SimpleCircuit.new(payload: client, max_failures: 100, retry_in: 60, logger: nil)
72
+ ```
73
+
74
+ The parameters are:
75
+
76
+ * `max_failures` — How many exceptions from the payload should be ignored (returned as is) before the circuit breaks and starts to fail fast.
77
+ * `retry_in` — How many seconds should pass before every retry to connect the payload (to send the original message) when the circuit is open (broken).
78
+ * `logger` — An object that responds to `warn(message)`. It will be called each time the circuit is broken.
79
+
80
+ ### Error Counting
81
+
82
+ The circuit counts exceptions coming from the payload **by class** and breaks only if **a particular class** of exceptions is received too many times.
83
+ It fails fast with the most occurred exception.
84
+
85
+ For example, if the payload occasionally throws `MultiJson::ParseError` and then starts throwing `HTTP::TimeoutError` on a regular basis, then the counter of `HTTP::TimeoutError` will reach the maximum first, and the circuit will fast-throw `HTTP::TimeoutError` after breaking.
86
+
87
+ It might be non-ideal. I welcome suggestions via issues or pull requests.
88
+
89
+ ## Development
90
+
91
+ After checking out the repo, run `bundle` to install dependencies. Then, run `bundle exec rake` to run the tests.
92
+
93
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `lib/circuit.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
94
+
95
+ ## Contributing
96
+
97
+ Bug reports and pull requests are welcome on GitHub at https://github.com/vassilevsky/circuit.
98
+
99
+ ## License
100
+
101
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,57 @@
1
+ class SimpleCircuit
2
+ VERSION = "0.1.0"
3
+
4
+ def initialize(payload:, max_failures: 100, retry_in: 60, logger: nil)
5
+ @payload = payload
6
+ @max_failures = max_failures
7
+ @retry_in = retry_in
8
+ @logger = logger
9
+
10
+ @mutex = Mutex.new
11
+
12
+ close
13
+ end
14
+
15
+ def pass(message, *args)
16
+ fail @e if open? && !time_to_retry?
17
+ result = payload.public_send(message, *args)
18
+ close if open?
19
+ result
20
+ rescue => e
21
+ raise e if open?
22
+ @e = e
23
+ @mutex.synchronize{ @failures[e.class] += 1 }
24
+ break! if @failures[e.class] > max_failures
25
+ raise e
26
+ end
27
+
28
+ def open?
29
+ !closed?
30
+ end
31
+
32
+ def closed?
33
+ @closed
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :payload
39
+ attr_reader :max_failures
40
+ attr_reader :retry_in
41
+ attr_reader :logger
42
+
43
+ def break!
44
+ @closed = false
45
+ @broken_at = Time.now
46
+ logger&.warn('#{self} has been broken')
47
+ end
48
+
49
+ def close
50
+ @closed = true
51
+ @failures = Hash.new(0)
52
+ end
53
+
54
+ def time_to_retry?
55
+ @broken_at + retry_in < Time.now
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "simple_circuit"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "simple_circuit"
8
+ spec.version = SimpleCircuit::VERSION
9
+ spec.authors = ["Ilya Vassilevsky"]
10
+ spec.email = ["vassilevsky@gmail.com"]
11
+
12
+ spec.summary = "Allows a service to fail fast to prevent overload"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.require_paths = ["lib"]
17
+
18
+ spec.add_development_dependency "bundler", "~> 1.15"
19
+ spec.add_development_dependency "rake", "~> 10.0"
20
+ spec.add_development_dependency "rspec", "~> 3.0"
21
+ end
@@ -0,0 +1,118 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe SimpleCircuit do
4
+ it "has a version number" do
5
+ expect(SimpleCircuit::VERSION).not_to be nil
6
+ end
7
+
8
+ context 'happy path' do
9
+ let(:foo){ Class.new{ def bar(baz); baz; end }.new }
10
+
11
+ it 'passes through a message and returns the result' do
12
+ expect(foo).to receive(:bar).with(:baz).and_call_original
13
+ expect(SimpleCircuit.new(payload: foo).pass(:bar, :baz)).to eq(:baz)
14
+ end
15
+ end
16
+
17
+ context 'when method call fails' do
18
+ class BarError < RuntimeError; end
19
+
20
+ let(:foo){ Class.new{ def bar; fail BarError; end }.new }
21
+ let(:circuit){ SimpleCircuit.new(payload: foo) }
22
+
23
+ it 'counts the error and lets it raise' do
24
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
25
+ end
26
+
27
+ context 'when circuit has too many errors' do
28
+ let(:circuit){ SimpleCircuit.new(payload: foo, max_failures: 1) }
29
+
30
+ it 'breaks the circuit - does not call the payload anymore, fails immediately' do
31
+ expect(circuit).to be_closed
32
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
33
+ expect(circuit).to be_closed
34
+ expect(foo).to receive(:bar).and_call_original
35
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
36
+ expect(circuit).not_to be_closed
37
+ expect(circuit).to be_open
38
+ expect(foo).not_to receive(:bar)
39
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
40
+ end
41
+
42
+ context 'testing circuit via logger' do
43
+ let(:logger){ double(:logger, warn: true) }
44
+ let(:circuit){ SimpleCircuit.new(payload: foo, max_failures: 1, logger: logger) }
45
+
46
+ it 'does not break again' do
47
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
48
+ expect(logger).to receive(:warn) do |message|
49
+ expect(message).to include('broken')
50
+ end
51
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
52
+ expect(logger).not_to receive(:warn)
53
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
54
+ end
55
+ end
56
+
57
+ context 'when the next call is successful again' do
58
+ let(:foo) do
59
+ Class.new do
60
+ def initialize
61
+ @messages = 0
62
+ end
63
+
64
+ def bar
65
+ @messages += 1
66
+
67
+ if @messages < 3
68
+ fail BarError
69
+ else
70
+ :baz
71
+ end
72
+ end
73
+ end.new
74
+ end
75
+
76
+ let(:circuit){ SimpleCircuit.new(payload: foo, max_failures: 1, retry_in: 1) }
77
+
78
+ it 'closes back the circuit' do
79
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
80
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
81
+ expect(circuit).to be_open
82
+ expect{circuit.pass(:bar)}.to raise_error(BarError)
83
+ sleep 1
84
+ expect(circuit.pass(:bar)).to eq(:baz)
85
+ expect(circuit).to be_closed
86
+ end
87
+ end
88
+
89
+ context 'when payload raises different errors' do
90
+ class Error1 < RuntimeError; end
91
+ class Error2 < RuntimeError; end
92
+
93
+ let(:foo) do
94
+ Class.new do
95
+ def initialize
96
+ @errors = [Error1, Error2, Error1]
97
+ end
98
+
99
+ def bar
100
+ fail @errors.shift
101
+ end
102
+ end.new
103
+ end
104
+
105
+ let(:circuit){ SimpleCircuit.new(payload: foo, max_failures: 1) }
106
+
107
+ it 'fails with top error after break' do
108
+ expect{circuit.pass(:bar)}.to raise_error(Error1)
109
+ expect{circuit.pass(:bar)}.to raise_error(Error2)
110
+ expect(circuit).to be_closed
111
+ expect{circuit.pass(:bar)}.to raise_error(Error1)
112
+ expect(circuit).to be_open
113
+ expect{circuit.pass(:bar)}.to raise_error(Error1)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,14 @@
1
+ require "bundler/setup"
2
+ require "simple_circuit"
3
+
4
+ RSpec.configure do |config|
5
+ # Enable flags like --only-failures and --next-failure
6
+ config.example_status_persistence_file_path = ".rspec_status"
7
+
8
+ # Disable RSpec exposing methods globally on `Module` and `main`
9
+ config.disable_monkey_patching!
10
+
11
+ config.expect_with :rspec do |c|
12
+ c.syntax = :expect
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: simple_circuit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ilya Vassilevsky
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-01-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ description:
56
+ email:
57
+ - vassilevsky@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".gitignore"
63
+ - ".rspec"
64
+ - ".travis.yml"
65
+ - Gemfile
66
+ - LICENSE.txt
67
+ - README.md
68
+ - Rakefile
69
+ - lib/simple_circuit.rb
70
+ - simple_circuit.gemspec
71
+ - spec/simple_circuit_spec.rb
72
+ - spec/spec_helper.rb
73
+ homepage:
74
+ licenses:
75
+ - MIT
76
+ metadata: {}
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubyforge_project:
93
+ rubygems_version: 2.7.6
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Allows a service to fail fast to prevent overload
97
+ test_files: []