stoplight 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,12 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Error
5
+ # @return [Class]
6
+ Base = Class.new(StandardError)
7
+ # @return [Class]
8
+ NoFallback = Class.new(Base)
9
+ # @return [Class]
10
+ NoRedis = Class.new(Base)
11
+ end
12
+ 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
@@ -0,0 +1,6 @@
1
+ # coding: utf-8
2
+
3
+ require 'coveralls'
4
+ Coveralls.wear!
5
+
6
+ require 'stoplight'
@@ -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