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.
@@ -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