simple_circuit 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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/Rakefile +6 -0
- data/lib/simple_circuit.rb +57 -0
- data/simple_circuit.gemspec +21 -0
- data/spec/simple_circuit_spec.rb +118 -0
- data/spec/spec_helper.rb +14 -0
- metadata +97 -0
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
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
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,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
|
data/spec/spec_helper.rb
ADDED
@@ -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: []
|