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
@@ -0,0 +1,72 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'redis'
|
5
|
+
REDIS_LOADED = true
|
6
|
+
rescue LoadError
|
7
|
+
REDIS_LOADED = false
|
8
|
+
end
|
9
|
+
|
10
|
+
module Stoplight
|
11
|
+
module DataStore
|
12
|
+
class Redis < Base
|
13
|
+
def initialize(*args)
|
14
|
+
fail Error::NoRedis unless REDIS_LOADED
|
15
|
+
|
16
|
+
@redis = ::Redis.new(*args)
|
17
|
+
end
|
18
|
+
|
19
|
+
def names
|
20
|
+
@redis.scan_each(match: "#{DataStore::KEY_PREFIX}:*:*").map do |key|
|
21
|
+
key[/^#{DataStore::KEY_PREFIX}:(.+):[^:]+$/o, 1]
|
22
|
+
end.uniq
|
23
|
+
end
|
24
|
+
|
25
|
+
def record_failure(name, error)
|
26
|
+
failure = Failure.new(error)
|
27
|
+
@redis.rpush(failure_key(name), failure.to_json)
|
28
|
+
# TODO: Trim failures (think ring buffer). Probably in a multi block.
|
29
|
+
end
|
30
|
+
|
31
|
+
def clear_failures(name)
|
32
|
+
@redis.del(failure_key(name))
|
33
|
+
end
|
34
|
+
|
35
|
+
def failures(name)
|
36
|
+
@redis.lrange(failure_key(name), 0, -1)
|
37
|
+
end
|
38
|
+
|
39
|
+
def threshold(name)
|
40
|
+
value = @redis.hget(settings_key(name), 'threshold')
|
41
|
+
Integer(value) if value
|
42
|
+
end
|
43
|
+
|
44
|
+
def set_threshold(name, threshold)
|
45
|
+
@redis.hset(settings_key(name), 'threshold', threshold)
|
46
|
+
threshold
|
47
|
+
end
|
48
|
+
|
49
|
+
def record_attempt(name)
|
50
|
+
@redis.incr(attempt_key(name))
|
51
|
+
end
|
52
|
+
|
53
|
+
def clear_attempts(name)
|
54
|
+
@redis.del(attempt_key(name))
|
55
|
+
end
|
56
|
+
|
57
|
+
def attempts(name)
|
58
|
+
@redis.get(attempt_key(name)).to_i
|
59
|
+
end
|
60
|
+
|
61
|
+
def state(name)
|
62
|
+
@redis.hget(settings_key(name), 'state') || DataStore::STATE_UNLOCKED
|
63
|
+
end
|
64
|
+
|
65
|
+
def set_state(name, state)
|
66
|
+
validate_state!(state)
|
67
|
+
@redis.hset(settings_key(name), 'state', state)
|
68
|
+
state
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Stoplight
|
6
|
+
class Failure
|
7
|
+
# @param error [Exception]
|
8
|
+
def initialize(error)
|
9
|
+
@error = error
|
10
|
+
@time = Time.now
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [String]
|
14
|
+
def to_json
|
15
|
+
JSON.dump(to_h)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def to_h
|
21
|
+
{ error: @error.inspect, time: @time }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
class Light
|
5
|
+
# @return [Integer]
|
6
|
+
DEFAULT_THRESHOLD = 3
|
7
|
+
|
8
|
+
# @return [Array<Exception>]
|
9
|
+
attr_reader :allowed_errors
|
10
|
+
|
11
|
+
# @return [Proc]
|
12
|
+
attr_reader :code
|
13
|
+
|
14
|
+
# @return [String]
|
15
|
+
attr_reader :name
|
16
|
+
|
17
|
+
# @param name [String]
|
18
|
+
# @yield []
|
19
|
+
def initialize(name, &code)
|
20
|
+
@allowed_errors = []
|
21
|
+
@code = code.to_proc
|
22
|
+
@name = name.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Object]
|
26
|
+
# @raise [Error::NoFallback]
|
27
|
+
# @see #fallback
|
28
|
+
# @see #green?
|
29
|
+
def run
|
30
|
+
sync_settings
|
31
|
+
|
32
|
+
if green?
|
33
|
+
run_code
|
34
|
+
else
|
35
|
+
run_fallback
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Fluent builders
|
40
|
+
|
41
|
+
# @param allowed_errors [Array<Exception>]
|
42
|
+
# @return [self]
|
43
|
+
def with_allowed_errors(allowed_errors)
|
44
|
+
@allowed_errors = allowed_errors.to_a
|
45
|
+
self
|
46
|
+
end
|
47
|
+
|
48
|
+
# @yield []
|
49
|
+
# @return [self]
|
50
|
+
def with_fallback(&fallback)
|
51
|
+
@fallback = fallback.to_proc
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
# @param threshold [Integer]
|
56
|
+
# @return [self]
|
57
|
+
def with_threshold(threshold)
|
58
|
+
Stoplight.data_store.set_threshold(name, threshold.to_i)
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
# Attribute readers
|
63
|
+
|
64
|
+
# @return [Object]
|
65
|
+
# @raise [Error::NoFallback]
|
66
|
+
def fallback
|
67
|
+
return @fallback if defined?(@fallback)
|
68
|
+
fail Error::NoFallback
|
69
|
+
end
|
70
|
+
|
71
|
+
# @return (see Stoplight.green?)
|
72
|
+
def green?
|
73
|
+
Stoplight.green?(name)
|
74
|
+
end
|
75
|
+
|
76
|
+
# @return (see Stoplight.red?)
|
77
|
+
def red?
|
78
|
+
!green?
|
79
|
+
end
|
80
|
+
|
81
|
+
# @return (see Stoplight.threshold)
|
82
|
+
def threshold
|
83
|
+
Stoplight.threshold(name)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def error_allowed?(error)
|
89
|
+
allowed_errors.any? { |klass| error.is_a?(klass) }
|
90
|
+
end
|
91
|
+
|
92
|
+
def run_code
|
93
|
+
result = code.call
|
94
|
+
Stoplight.data_store.clear_failures(name)
|
95
|
+
result
|
96
|
+
rescue => error
|
97
|
+
if error_allowed?(error)
|
98
|
+
Stoplight.data_store.clear_failures(name)
|
99
|
+
else
|
100
|
+
Stoplight.data_store.record_failure(name, error)
|
101
|
+
end
|
102
|
+
|
103
|
+
raise
|
104
|
+
end
|
105
|
+
|
106
|
+
def run_fallback
|
107
|
+
Stoplight.data_store.record_attempt(name)
|
108
|
+
fallback.call
|
109
|
+
end
|
110
|
+
|
111
|
+
def sync_settings
|
112
|
+
Stoplight.data_store.set_threshold(name, threshold)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe Stoplight::DataStore::Base do
|
6
|
+
subject(:data_store) { described_class.new }
|
7
|
+
|
8
|
+
%w(
|
9
|
+
attempts
|
10
|
+
clear_attempts
|
11
|
+
clear_failures
|
12
|
+
failures
|
13
|
+
names
|
14
|
+
record_attempt
|
15
|
+
record_failure
|
16
|
+
set_state
|
17
|
+
set_threshold
|
18
|
+
state
|
19
|
+
threshold
|
20
|
+
).each do |method|
|
21
|
+
it "responds to #{method}" do
|
22
|
+
expect(data_store).to respond_to(method)
|
23
|
+
end
|
24
|
+
|
25
|
+
it "does not implement #{method}" do
|
26
|
+
args = [nil] * data_store.method(method).arity
|
27
|
+
expect { data_store.public_send(method, *args) }.to raise_error(
|
28
|
+
NotImplementedError)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe Stoplight::DataStore::Memory do
|
6
|
+
let(:error) { error_klass.new }
|
7
|
+
let(:error_klass) { Class.new(StandardError) }
|
8
|
+
let(:name) { SecureRandom.hex }
|
9
|
+
let(:state) { Stoplight::DataStore::STATE_LOCKED_GREEN }
|
10
|
+
let(:threshold) { rand(10) }
|
11
|
+
|
12
|
+
subject(:data_store) { described_class.new }
|
13
|
+
|
14
|
+
describe '#attempts' do
|
15
|
+
subject(:result) { data_store.attempts(name) }
|
16
|
+
|
17
|
+
it 'is zero' do
|
18
|
+
expect(result).to eql(0)
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'with an attempt' do
|
22
|
+
before { data_store.record_attempt(name) }
|
23
|
+
|
24
|
+
it 'is one' do
|
25
|
+
expect(result).to eql(1)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#clear_attempts' do
|
31
|
+
subject(:result) { data_store.clear_attempts(name) }
|
32
|
+
|
33
|
+
it 'returns nil' do
|
34
|
+
expect(result).to be_nil
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'with an attempt' do
|
38
|
+
before { data_store.record_attempt(name) }
|
39
|
+
|
40
|
+
it 'returns one' do
|
41
|
+
expect(result).to eql(1)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'clears attempts' do
|
45
|
+
result
|
46
|
+
expect(data_store.attempts(name)).to eql(0)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe '#clear_failures' do
|
52
|
+
subject(:result) { data_store.clear_failures(name) }
|
53
|
+
|
54
|
+
it 'returns nil' do
|
55
|
+
expect(result).to be nil
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'with a failure' do
|
59
|
+
before { data_store.record_failure(name, error) }
|
60
|
+
|
61
|
+
it 'returns the failures' do
|
62
|
+
failures = data_store.failures(name)
|
63
|
+
expect(result).to eql(failures)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'clears the failures' do
|
67
|
+
result
|
68
|
+
expect(data_store.failures(name)).to be_empty
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe '#threshold' do
|
74
|
+
subject(:result) { data_store.threshold(name) }
|
75
|
+
|
76
|
+
it 'is nil' do
|
77
|
+
expect(result).to be nil
|
78
|
+
end
|
79
|
+
|
80
|
+
context 'with a threshold' do
|
81
|
+
before { data_store.set_threshold(name, threshold) }
|
82
|
+
|
83
|
+
it 'returns the threshold' do
|
84
|
+
expect(result).to eql(threshold)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe '#failures' do
|
90
|
+
subject(:result) { data_store.failures(name) }
|
91
|
+
|
92
|
+
it 'is an array' do
|
93
|
+
expect(result).to be_an(Array)
|
94
|
+
end
|
95
|
+
|
96
|
+
it 'is empty' do
|
97
|
+
expect(result).to be_empty
|
98
|
+
end
|
99
|
+
|
100
|
+
context 'with a failure' do
|
101
|
+
before { data_store.record_failure(name, error) }
|
102
|
+
|
103
|
+
it 'returns the failures' do
|
104
|
+
expect(result.size).to eql(1)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe '#names' do
|
110
|
+
subject(:result) { data_store.names }
|
111
|
+
|
112
|
+
it 'is an array' do
|
113
|
+
expect(result).to be_an(Array)
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'is empty' do
|
117
|
+
expect(result).to be_empty
|
118
|
+
end
|
119
|
+
|
120
|
+
context 'with a name' do
|
121
|
+
before { data_store.settings(name) }
|
122
|
+
|
123
|
+
it 'includes the name' do
|
124
|
+
expect(result).to include(name)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
describe '#record_attempt' do
|
130
|
+
subject(:result) { data_store.record_attempt(name) }
|
131
|
+
|
132
|
+
it 'records the attempt' do
|
133
|
+
result
|
134
|
+
expect(data_store.attempts(name)).to eql(1)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
describe '#record_failure' do
|
139
|
+
subject(:result) { data_store.record_failure(name, error) }
|
140
|
+
|
141
|
+
it 'returns the failures' do
|
142
|
+
expect(result).to eql(data_store.failures(name))
|
143
|
+
end
|
144
|
+
|
145
|
+
it 'logs the failure' do
|
146
|
+
expect(result.size).to eql(1)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
describe '#set_threshold' do
|
151
|
+
subject(:result) { data_store.set_threshold(name, threshold) }
|
152
|
+
|
153
|
+
it 'returns the threshold' do
|
154
|
+
expect(result).to eql(threshold)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
describe '#set_state' do
|
159
|
+
subject(:result) { data_store.set_state(name, state) }
|
160
|
+
|
161
|
+
it 'returns the state' do
|
162
|
+
expect(result).to eql(state)
|
163
|
+
end
|
164
|
+
|
165
|
+
context 'with an invalid state' do
|
166
|
+
let(:state) { SecureRandom.hex }
|
167
|
+
|
168
|
+
it 'raises an error' do
|
169
|
+
expect { result }.to raise_error(ArgumentError)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
describe '#state' do
|
175
|
+
subject(:result) { data_store.state(name) }
|
176
|
+
|
177
|
+
it 'returns the default state' do
|
178
|
+
expect(result).to eql(Stoplight::DataStore::STATE_UNLOCKED)
|
179
|
+
end
|
180
|
+
|
181
|
+
context 'with a custom state' do
|
182
|
+
before { data_store.set_state(name, state) }
|
183
|
+
|
184
|
+
it 'returns the state' do
|
185
|
+
expect(result).to eql(state)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|