stoplight 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.md +18 -0
- data/README.md +208 -0
- data/lib/stoplight.rb +65 -0
- data/lib/stoplight/data_store.rb +15 -0
- data/lib/stoplight/data_store/base.rb +96 -0
- data/lib/stoplight/data_store/memory.rb +67 -0
- data/lib/stoplight/data_store/redis.rb +72 -0
- data/lib/stoplight/error.rb +12 -0
- data/lib/stoplight/failure.rb +24 -0
- data/lib/stoplight/light.rb +115 -0
- data/spec/spec_helper.rb +6 -0
- data/spec/stoplight/data_store/base_spec.rb +31 -0
- data/spec/stoplight/data_store/memory_spec.rb +189 -0
- data/spec/stoplight/data_store/redis_spec.rb +190 -0
- data/spec/stoplight/failure_spec.rb +23 -0
- data/spec/stoplight/light_spec.rb +222 -0
- data/spec/stoplight_spec.rb +128 -0
- metadata +156 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c9bd1ff4a7ba5920b6b1a5e98c829b88ca495ec2
|
4
|
+
data.tar.gz: 3c7698e907ec212e7072b14c9d4df4e54dd9c1b9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4e9fe6173c1b7524fe02ec8939512aea3c0a4cb5cf09ccf5f6cacde09ed461fc6ccfc1b4fc37eaf5008206b398d2aab9b71f5d9c1c39b94ded8ef4a57fb7b942
|
7
|
+
data.tar.gz: f6a8ffb8069abad09d6a795c5acc368b6ea954e8d9d3c6359d36b61e54625923727834a49b1ac827c55cda32d2e36a9b7d16ae9fc9939cd2f35cf14870e93a7b
|
data/CHANGELOG.md
ADDED
data/LICENSE.md
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Copyright (c) 2014 Cameron Desautels & Taylor Fausak
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
5
|
+
the Software without restriction, including without limitation the rights to
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
7
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
8
|
+
subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
15
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
16
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,208 @@
|
|
1
|
+
# [Stoplight][1]
|
2
|
+
|
3
|
+
[![Gem version][2]][3]
|
4
|
+
[![Build status][4]][5]
|
5
|
+
[![Coverage status][6]][7]
|
6
|
+
[![Quality status][8]][9]
|
7
|
+
[![Dependency status][10]][11]
|
8
|
+
|
9
|
+
Traffic control for code. An implementation of the circuit breaker pattern in Ruby.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add it to your Gemfile:
|
14
|
+
|
15
|
+
``` rb
|
16
|
+
gem 'stoplight', '~> 0.1.0'
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it manually:
|
20
|
+
|
21
|
+
``` sh
|
22
|
+
$ gem install stoplight
|
23
|
+
```
|
24
|
+
|
25
|
+
This project uses [Semantic Versioning][12].
|
26
|
+
|
27
|
+
## Setup
|
28
|
+
|
29
|
+
Stoplight uses an in-memory data store out of the box.
|
30
|
+
|
31
|
+
``` irb
|
32
|
+
>> require 'stoplight'
|
33
|
+
=> true
|
34
|
+
>> Stoplight.data_store
|
35
|
+
=> #<Stoplight::DataStore::Memory:...>
|
36
|
+
```
|
37
|
+
|
38
|
+
If you want to use a persistent data store, you'll have to set it up. Currently
|
39
|
+
the only supported persistent data store is Redis. Make sure you have [the Redis
|
40
|
+
gem][13] installed before configuring Stoplight.
|
41
|
+
|
42
|
+
``` irb
|
43
|
+
>> redis = Stoplight::DataStore::Redis.new(url: 'redis://127.0.0.1:6379/0')
|
44
|
+
=> #<Stoplight::DataStore::Redis:...>
|
45
|
+
>> Stoplight.data_store(redis)
|
46
|
+
=> #<Stoplight::DataStore::Redis:...>
|
47
|
+
```
|
48
|
+
|
49
|
+
### Rails
|
50
|
+
|
51
|
+
Stoplight is designed to work seamlessly with Rails. If you want to use the
|
52
|
+
in-memory data store, you don't need to do anything special. If you want to use
|
53
|
+
a persistent data store, you'll need to configure it. Create an initializer for
|
54
|
+
Stoplight:
|
55
|
+
|
56
|
+
``` rb
|
57
|
+
# config/initializers/stoplight.rb
|
58
|
+
require 'stoplight'
|
59
|
+
Stoplight.data_store(Stoplight::DataStore::Redis.new(...))
|
60
|
+
```
|
61
|
+
|
62
|
+
## Usage
|
63
|
+
|
64
|
+
To get started, create a stoplight:
|
65
|
+
|
66
|
+
``` irb
|
67
|
+
>> light = Stoplight::Light.new('example-1') { 22.0 / 7 }
|
68
|
+
=> #<Stoplight::Light:...>
|
69
|
+
```
|
70
|
+
|
71
|
+
Then you can run it and it will return the result of calling the block. This is
|
72
|
+
the "green" state.
|
73
|
+
|
74
|
+
``` irb
|
75
|
+
>> light.run
|
76
|
+
=> 3.142857142857143
|
77
|
+
>> light.green?
|
78
|
+
=> true
|
79
|
+
```
|
80
|
+
|
81
|
+
If everything goes well, you shouldn't even be able to tell that you're using a
|
82
|
+
stoplight. That's not very interesting though. Let's create a failing stoplight:
|
83
|
+
|
84
|
+
``` irb
|
85
|
+
>> light = Stoplight::Light.new('example-2') { 1 / 0 }
|
86
|
+
=> #<Stoplight::Light:...>
|
87
|
+
```
|
88
|
+
|
89
|
+
Now when you run it, the error will be recorded and passed through. After
|
90
|
+
running it a few times, the stoplight will stop trying and fail fast. This is
|
91
|
+
the "red" state.
|
92
|
+
|
93
|
+
``` irb
|
94
|
+
>> light.run
|
95
|
+
ZeroDivisionError: divided by 0
|
96
|
+
>> light.run
|
97
|
+
ZeroDivisionError: divided by 0
|
98
|
+
>> light.run
|
99
|
+
ZeroDivisionError: divided by 0
|
100
|
+
>> light.run
|
101
|
+
Stoplight::Error::NoFallback: Stoplight::Error::NoFallback
|
102
|
+
>> light.red?
|
103
|
+
=> true
|
104
|
+
```
|
105
|
+
|
106
|
+
### Custom errors
|
107
|
+
|
108
|
+
Some errors shouldn't cause your stoplight to move into the red state. Usually
|
109
|
+
these are handled elsewhere in your stack and don't represent real failures. A
|
110
|
+
good example is `ActiveRecord::RecordNotFound`.
|
111
|
+
|
112
|
+
``` irb
|
113
|
+
>> light = Stoplight::Light.new('example-3') { User.find(123) }.
|
114
|
+
?> with_allowed_errors([ActiveRecord::RecordNotFound])
|
115
|
+
=> #<Stoplight::Light:...>
|
116
|
+
>> light.run
|
117
|
+
ActiveRecord::RecordNotFound: Couldn't find User with ID=123
|
118
|
+
>> light.run
|
119
|
+
ActiveRecord::RecordNotFound: Couldn't find User with ID=123
|
120
|
+
>> light.run
|
121
|
+
ActiveRecord::RecordNotFound: Couldn't find User with ID=123
|
122
|
+
>> light.green?
|
123
|
+
=> true
|
124
|
+
```
|
125
|
+
|
126
|
+
### Custom fallback
|
127
|
+
|
128
|
+
Instead of raising a `Stoplight::Error::NoFallback` error when in the red state,
|
129
|
+
you can provide a block to be run. This is useful when there's a good default
|
130
|
+
value for the block.
|
131
|
+
|
132
|
+
``` irb
|
133
|
+
>> light = Stoplight::Light.new('example-4') { fail }.
|
134
|
+
?> with_fallback { [] }
|
135
|
+
=> #<Stoplight::Light:...>
|
136
|
+
>> light.run
|
137
|
+
RuntimeError:
|
138
|
+
>> light.run
|
139
|
+
RuntimeError:
|
140
|
+
>> light.run
|
141
|
+
RuntimeError:
|
142
|
+
>> light.run
|
143
|
+
=> []
|
144
|
+
```
|
145
|
+
|
146
|
+
### Custom threshold
|
147
|
+
|
148
|
+
Some bits of code might be allowed to fail more or less frequently than others.
|
149
|
+
You can configure this by setting a custom threshold in seconds.
|
150
|
+
|
151
|
+
``` irb
|
152
|
+
>> light = Stoplight::Light.new('example-5') { fail }.
|
153
|
+
?> with_threshold(1)
|
154
|
+
=> #<Stoplight::Light:...>
|
155
|
+
>> light.run
|
156
|
+
RuntimeError:
|
157
|
+
>> light.run
|
158
|
+
Stoplight::Error::NoFallback: Stoplight::Error::NoFallback
|
159
|
+
```
|
160
|
+
|
161
|
+
### Rails
|
162
|
+
|
163
|
+
Stoplight was designed to wrap Rails actions with minimal effort. Here's an
|
164
|
+
example configuration:
|
165
|
+
|
166
|
+
``` rb
|
167
|
+
class ApplicationController < ActionController::Base
|
168
|
+
around_action :stoplight
|
169
|
+
private
|
170
|
+
def stoplight(&block)
|
171
|
+
Stoplight::Light.new("#{params[:controller]}##{params[:action]}", &block)
|
172
|
+
.with_allowed_errors([ActiveRecord::RecordNotFound])
|
173
|
+
.with_fallback { render(nothing: true, status: :service_unavailable) }
|
174
|
+
.run
|
175
|
+
end
|
176
|
+
end
|
177
|
+
```
|
178
|
+
|
179
|
+
## Credits
|
180
|
+
|
181
|
+
Stoplight is brought to you by [@camdez][14] and [@tfausak][15] from [@OrgSync][16]. We were
|
182
|
+
inspired by Martin Fowler's [CircuitBreaker][17] article.
|
183
|
+
|
184
|
+
If this gem isn't cutting it for you, there are a few alternatives, including:
|
185
|
+
[circuit_b][18], [circuit_breaker][19], [simple_circuit_breaker][20], and
|
186
|
+
[ya_circuit_breaker][21].
|
187
|
+
|
188
|
+
[1]: https://github.com/orgsync/stoplight
|
189
|
+
[2]: https://badge.fury.io/rb/stoplight.svg
|
190
|
+
[3]: https://rubygems.org/gems/stoplight
|
191
|
+
[4]: https://travis-ci.org/orgsync/stoplight.svg
|
192
|
+
[5]: https://travis-ci.org/orgsync/stoplight
|
193
|
+
[6]: https://img.shields.io/coveralls/orgsync/stoplight.svg
|
194
|
+
[7]: https://coveralls.io/r/orgsync/stoplight
|
195
|
+
[8]: https://codeclimate.com/github/orgsync/stoplight/badges/gpa.svg
|
196
|
+
[9]: https://codeclimate.com/github/orgsync/stoplight
|
197
|
+
[10]: https://gemnasium.com/orgsync/stoplight.svg
|
198
|
+
[11]: https://gemnasium.com/orgsync/stoplight
|
199
|
+
[12]: http://semver.org/spec/v2.0.0.html
|
200
|
+
[13]: https://rubygems.org/gems/redis
|
201
|
+
[14]: https://github.com/camdez
|
202
|
+
[15]: https://github.com/tfausak
|
203
|
+
[16]: https://github.com/OrgSync
|
204
|
+
[17]: http://martinfowler.com/bliki/CircuitBreaker.html
|
205
|
+
[18]: https://github.com/alg/circuit_b
|
206
|
+
[19]: https://github.com/wsargent/circuit_breaker
|
207
|
+
[20]: https://github.com/soundcloud/simple_circuit_breaker
|
208
|
+
[21]: https://github.com/wooga/circuit_breaker
|
data/lib/stoplight.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'stoplight/data_store'
|
5
|
+
require 'stoplight/data_store/base'
|
6
|
+
require 'stoplight/data_store/memory'
|
7
|
+
require 'stoplight/data_store/redis'
|
8
|
+
require 'stoplight/error'
|
9
|
+
require 'stoplight/failure'
|
10
|
+
require 'stoplight/light'
|
11
|
+
|
12
|
+
module Stoplight
|
13
|
+
# @return [Gem::Version]
|
14
|
+
VERSION = Gem::Version.new('0.1.0')
|
15
|
+
|
16
|
+
class << self
|
17
|
+
extend Forwardable
|
18
|
+
|
19
|
+
def_delegators :data_store, *%w(
|
20
|
+
attempts
|
21
|
+
clear_attempts
|
22
|
+
clear_failures
|
23
|
+
failures
|
24
|
+
names
|
25
|
+
record_attempt
|
26
|
+
record_failure
|
27
|
+
set_state
|
28
|
+
set_threshold
|
29
|
+
state
|
30
|
+
)
|
31
|
+
|
32
|
+
# @param data_store [DataStore::Base]
|
33
|
+
# @return [DataStore::Base]
|
34
|
+
def data_store(data_store = nil)
|
35
|
+
@data_store = data_store if data_store
|
36
|
+
@data_store = DataStore::Memory.new unless defined?(@data_store)
|
37
|
+
@data_store
|
38
|
+
end
|
39
|
+
|
40
|
+
# @param name [String]
|
41
|
+
# @return [Boolean]
|
42
|
+
def green?(name)
|
43
|
+
case data_store.state(name)
|
44
|
+
when DataStore::STATE_LOCKED_GREEN
|
45
|
+
true
|
46
|
+
when DataStore::STATE_LOCKED_RED
|
47
|
+
false
|
48
|
+
else
|
49
|
+
data_store.failures(name).size < threshold(name)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# @param name [String]
|
54
|
+
# @return (see .green?)
|
55
|
+
def red?(name)
|
56
|
+
!green?(name)
|
57
|
+
end
|
58
|
+
|
59
|
+
# @param name [String]
|
60
|
+
# @return [Integer]
|
61
|
+
def threshold(name)
|
62
|
+
data_store.threshold(name) || Light::DEFAULT_THRESHOLD
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module DataStore
|
5
|
+
# @return [String]
|
6
|
+
KEY_PREFIX = 'stoplight'
|
7
|
+
|
8
|
+
# @return [Set<String>]
|
9
|
+
STATES = Set.new([
|
10
|
+
STATE_LOCKED_GREEN = 'locked_green',
|
11
|
+
STATE_LOCKED_RED = 'locked_red',
|
12
|
+
STATE_UNLOCKED = 'unlocked'
|
13
|
+
]).freeze
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module DataStore
|
5
|
+
class Base
|
6
|
+
# @return [Array<String>]
|
7
|
+
def names
|
8
|
+
fail NotImplementedError
|
9
|
+
end
|
10
|
+
|
11
|
+
# @param _name [String]
|
12
|
+
# @param _error [Exception]
|
13
|
+
def record_failure(_name, _error)
|
14
|
+
fail NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
# @param _name [String]
|
18
|
+
def clear_failures(_name)
|
19
|
+
fail NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
# @param _name [String]
|
23
|
+
# @return [Array<Failure>]
|
24
|
+
def failures(_name)
|
25
|
+
fail NotImplementedError
|
26
|
+
end
|
27
|
+
|
28
|
+
# @param _name [String]
|
29
|
+
# @return [Integer]
|
30
|
+
def threshold(_name)
|
31
|
+
fail NotImplementedError
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param _name [String]
|
35
|
+
# @param _threshold [Integer]
|
36
|
+
# @return (see #threshold)
|
37
|
+
def set_threshold(_name, _threshold)
|
38
|
+
fail NotImplementedError
|
39
|
+
end
|
40
|
+
|
41
|
+
# @param _name [String]
|
42
|
+
# @return (see #attempts)
|
43
|
+
def record_attempt(_name)
|
44
|
+
fail NotImplementedError
|
45
|
+
end
|
46
|
+
|
47
|
+
# @param _name [String]
|
48
|
+
def clear_attempts(_name)
|
49
|
+
fail NotImplementedError
|
50
|
+
end
|
51
|
+
|
52
|
+
# @param _name [String]
|
53
|
+
# @return [Integer]
|
54
|
+
def attempts(_name)
|
55
|
+
fail NotImplementedError
|
56
|
+
end
|
57
|
+
|
58
|
+
# @param _name [String]
|
59
|
+
# @return [String]
|
60
|
+
def state(_name)
|
61
|
+
fail NotImplementedError
|
62
|
+
end
|
63
|
+
|
64
|
+
# @param _name [String]
|
65
|
+
# @param _state [String]
|
66
|
+
# @return [String]
|
67
|
+
def set_state(_name, _state)
|
68
|
+
# REVIEW: Should we clear failures here?
|
69
|
+
fail NotImplementedError
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def validate_state!(state)
|
75
|
+
return if DataStore::STATES.include?(state)
|
76
|
+
fail ArgumentError, 'Invalid state'
|
77
|
+
end
|
78
|
+
|
79
|
+
def key(name, slug)
|
80
|
+
[DataStore::KEY_PREFIX, name, slug].join(':')
|
81
|
+
end
|
82
|
+
|
83
|
+
def attempt_key(name)
|
84
|
+
key(name, 'attempts')
|
85
|
+
end
|
86
|
+
|
87
|
+
def failure_key(name)
|
88
|
+
key(name, 'failures')
|
89
|
+
end
|
90
|
+
|
91
|
+
def settings_key(name)
|
92
|
+
key(name, 'settings')
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module DataStore
|
5
|
+
class Memory < Base
|
6
|
+
def initialize
|
7
|
+
@data = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def names
|
11
|
+
@data.keys.map do |key|
|
12
|
+
match = /^#{DataStore::KEY_PREFIX}:(.+):([^:]+)$/.match(key)
|
13
|
+
match[1] if match
|
14
|
+
end.compact.uniq
|
15
|
+
end
|
16
|
+
|
17
|
+
def record_failure(name, error)
|
18
|
+
failure = Failure.new(error)
|
19
|
+
array = @data[failure_key(name)] ||= []
|
20
|
+
array.push(failure)
|
21
|
+
end
|
22
|
+
|
23
|
+
def clear_failures(name)
|
24
|
+
@data.delete(failure_key(name))
|
25
|
+
end
|
26
|
+
|
27
|
+
def failures(name)
|
28
|
+
@data[failure_key(name)] || []
|
29
|
+
end
|
30
|
+
|
31
|
+
def settings(name)
|
32
|
+
@data[settings_key(name)] ||= {}
|
33
|
+
end
|
34
|
+
|
35
|
+
def threshold(name)
|
36
|
+
settings(name)['threshold']
|
37
|
+
end
|
38
|
+
|
39
|
+
def set_threshold(name, threshold)
|
40
|
+
settings(name)['threshold'] = threshold
|
41
|
+
end
|
42
|
+
|
43
|
+
def record_attempt(name)
|
44
|
+
key = attempt_key(name)
|
45
|
+
@data[key] ||= 0
|
46
|
+
@data[key] += 1
|
47
|
+
end
|
48
|
+
|
49
|
+
def clear_attempts(name)
|
50
|
+
@data.delete(attempt_key(name))
|
51
|
+
end
|
52
|
+
|
53
|
+
def attempts(name)
|
54
|
+
@data[attempt_key(name)] || 0
|
55
|
+
end
|
56
|
+
|
57
|
+
def state(name)
|
58
|
+
settings(name)['state'] || DataStore::STATE_UNLOCKED
|
59
|
+
end
|
60
|
+
|
61
|
+
def set_state(name, state)
|
62
|
+
validate_state!(state)
|
63
|
+
settings(name)['state'] = state
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|