stoplight 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/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
|