berater 0.3.0 → 0.6.2
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 +4 -4
- data/lib/berater.rb +27 -38
- data/lib/berater/concurrency_limiter.rb +53 -45
- data/lib/berater/dsl.rb +20 -9
- data/lib/berater/inhibitor.rb +4 -2
- data/lib/berater/limiter.rb +68 -4
- data/lib/berater/lock.rb +4 -14
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +64 -63
- data/lib/berater/rspec.rb +3 -1
- data/lib/berater/rspec/matchers.rb +57 -36
- data/lib/berater/test_mode.rb +21 -21
- data/lib/berater/unlimiter.rb +8 -12
- data/lib/berater/utils.rb +46 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +33 -70
- data/spec/concurrency_limiter_spec.rb +166 -64
- data/spec/dsl_refinement_spec.rb +46 -0
- data/spec/dsl_spec.rb +72 -0
- data/spec/inhibitor_spec.rb +2 -4
- data/spec/limiter_spec.rb +107 -0
- data/spec/lua_script_spec.rb +97 -0
- data/spec/matchers_spec.rb +71 -3
- data/spec/rate_limiter_spec.rb +132 -94
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +123 -81
- data/spec/unlimiter_spec.rb +3 -9
- data/spec/utils_spec.rb +78 -0
- metadata +31 -3
data/spec/riddle_spec.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
# Can you build a rate limiter from a concurrency limiter? Yes!
|
2
|
+
|
3
|
+
class RateRiddler
|
4
|
+
def self.limit(capacity, interval)
|
5
|
+
lock = Berater::ConcurrencyLimiter.new(:key, capacity, timeout: interval).limit
|
6
|
+
yield if block_given?
|
7
|
+
# allow lock to time out rather than be released
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
describe 'a ConcurrencyLimiter-derived rate limiter' do
|
13
|
+
def limit(&block)
|
14
|
+
RateRiddler.limit(1, :second, &block)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'works' do
|
18
|
+
expect(limit { 123 }).to eq 123
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'respects limits' do
|
22
|
+
limit
|
23
|
+
expect { limit }.to be_overloaded
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'resets over time' do
|
27
|
+
limit
|
28
|
+
expect { limit }.to be_overloaded
|
29
|
+
|
30
|
+
Timecop.freeze(1)
|
31
|
+
|
32
|
+
limit
|
33
|
+
expect { limit }.to be_overloaded
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
# Can you build a concurrency limiter from a rate limiter? Almost...
|
39
|
+
|
40
|
+
class ConcurrenyRiddler
|
41
|
+
def self.limit(capacity, timeout: nil)
|
42
|
+
timeout ||= 1_000 # fake infinity
|
43
|
+
|
44
|
+
limiter = Berater::RateLimiter.new(:key, capacity, timeout)
|
45
|
+
limiter.limit
|
46
|
+
yield if block_given?
|
47
|
+
ensure
|
48
|
+
# decrement counter
|
49
|
+
limiter.redis.decr(limiter.send(:cache_key, :key))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
describe 'a RateLimiter-derived concurrency limiter' do
|
55
|
+
def limit(capacity = 1, timeout: nil, &block)
|
56
|
+
ConcurrenyRiddler.limit(capacity, timeout: timeout, &block)
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'works' do
|
60
|
+
expect(limit { 123 }).to eq 123
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'respects limits' do
|
64
|
+
limit do
|
65
|
+
# a second, simultaneous request isn't allowed
|
66
|
+
expect { limit }.to be_overloaded
|
67
|
+
end
|
68
|
+
|
69
|
+
# but now it'll work
|
70
|
+
limit
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'resets over time' do
|
74
|
+
limit(timeout: 1) do
|
75
|
+
expect { limit }.to be_overloaded
|
76
|
+
|
77
|
+
# ...wait for it
|
78
|
+
Timecop.freeze(10)
|
79
|
+
|
80
|
+
limit(timeout: 1)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
it "has no memory of the order, so timeouts don't work quite right" do
|
85
|
+
limit(2, timeout: 1) do
|
86
|
+
Timecop.freeze(0.5)
|
87
|
+
|
88
|
+
limit(2, timeout: 1) do
|
89
|
+
# this is where the masquerading breaks. the first lock is still
|
90
|
+
# being held and within it's timeout limit, however the RaterLimiter
|
91
|
+
# decremented the count internally since enough time has passed.
|
92
|
+
# This next call *should* fail, but doesn't.
|
93
|
+
|
94
|
+
expect {
|
95
|
+
expect { limit(2, timeout: 1) }.to be_overloaded
|
96
|
+
}.to fail
|
97
|
+
|
98
|
+
# ...close, but not quite!
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/spec/test_mode_spec.rb
CHANGED
@@ -1,84 +1,147 @@
|
|
1
|
-
|
1
|
+
describe Berater::TestMode do
|
2
|
+
after do
|
3
|
+
Berater.test_mode = nil
|
4
|
+
end
|
5
|
+
|
6
|
+
context 'after test_mode.rb has been loaded' do
|
7
|
+
it 'monkey patches Berater' do
|
8
|
+
expect(Berater).to respond_to(:test_mode)
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'defaults to off' do
|
12
|
+
expect(Berater.test_mode).to be nil
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'prepends Limiter subclasses' do
|
16
|
+
expect(Berater::Unlimiter.ancestors).to include(described_class)
|
17
|
+
expect(Berater::Inhibitor.ancestors).to include(described_class)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'preserves the original functionality via super' do
|
21
|
+
expect { Berater::Limiter.new }.to raise_error(NoMethodError)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '.test_mode' do
|
26
|
+
it 'can be turned on' do
|
27
|
+
Berater.test_mode = :pass
|
28
|
+
expect(Berater.test_mode).to be :pass
|
29
|
+
|
30
|
+
Berater.test_mode = :fail
|
31
|
+
expect(Berater.test_mode).to be :fail
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'can be turned off' do
|
35
|
+
Berater.test_mode = nil
|
36
|
+
expect(Berater.test_mode).to be nil
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'validates input' do
|
40
|
+
expect { Berater.test_mode = :foo }.to raise_error(ArgumentError)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'works no matter when limiter was created' do
|
44
|
+
limiter = Berater::Unlimiter.new
|
45
|
+
expect(limiter).not_to be_overloaded
|
46
|
+
|
47
|
+
Berater.test_mode = :fail
|
48
|
+
expect(limiter).to be_overloaded
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'supports a generic expectation' do
|
52
|
+
Berater.test_mode = :pass
|
53
|
+
expect_any_instance_of(Berater::Limiter).to receive(:limit)
|
54
|
+
Berater::Unlimiter.new.limit
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
shared_examples 'it always works, without redis' do
|
59
|
+
before do
|
60
|
+
Berater.redis = nil
|
61
|
+
expect_any_instance_of(Berater::LuaScript).not_to receive(:eval)
|
62
|
+
end
|
63
|
+
|
64
|
+
it_behaves_like 'it is not overloaded'
|
65
|
+
|
66
|
+
it 'always works' do
|
67
|
+
10.times { subject.limit }
|
68
|
+
end
|
69
|
+
end
|
2
70
|
|
3
|
-
|
4
|
-
|
71
|
+
shared_examples 'it never works, without redis' do
|
72
|
+
before do
|
73
|
+
Berater.redis = nil
|
74
|
+
expect_any_instance_of(Berater::LuaScript).not_to receive(:eval)
|
75
|
+
end
|
76
|
+
|
77
|
+
it_behaves_like 'it is overloaded'
|
78
|
+
end
|
5
79
|
|
6
80
|
describe 'Unlimiter' do
|
7
|
-
|
81
|
+
subject { Berater::Unlimiter.new }
|
8
82
|
|
9
83
|
context 'when test_mode = nil' do
|
10
84
|
before { Berater.test_mode = nil }
|
11
85
|
|
12
|
-
it {
|
13
|
-
|
14
|
-
it 'works per usual' do
|
15
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
16
|
-
10.times { expect(limiter.limit).to be_a Berater::Lock }
|
17
|
-
end
|
86
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
87
|
+
it_behaves_like 'it always works, without redis'
|
18
88
|
end
|
19
89
|
|
20
90
|
context 'when test_mode = :pass' do
|
21
91
|
before { Berater.test_mode = :pass }
|
22
92
|
|
23
|
-
it {
|
24
|
-
|
25
|
-
it 'always works' do
|
26
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
27
|
-
10.times { expect(limiter.limit).to be_a Berater::Lock }
|
28
|
-
end
|
93
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
94
|
+
it_behaves_like 'it always works, without redis'
|
29
95
|
end
|
30
96
|
|
31
97
|
context 'when test_mode = :fail' do
|
32
98
|
before { Berater.test_mode = :fail }
|
33
99
|
|
34
|
-
it {
|
100
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
101
|
+
it_behaves_like 'it never works, without redis'
|
35
102
|
|
36
|
-
it '
|
37
|
-
expect
|
103
|
+
it 'supports class specific logic' do
|
104
|
+
expect(subject.overloaded?).to be true
|
105
|
+
expect { subject.limit }.to raise_error(Berater::Overloaded)
|
38
106
|
end
|
39
107
|
end
|
40
108
|
end
|
41
109
|
|
42
110
|
describe 'Inhibitor' do
|
43
|
-
|
111
|
+
subject { Berater::Inhibitor.new }
|
44
112
|
|
45
113
|
context 'when test_mode = nil' do
|
46
114
|
before { Berater.test_mode = nil }
|
47
115
|
|
48
|
-
it {
|
49
|
-
|
50
|
-
it 'works per usual' do
|
51
|
-
expect { limiter }.to be_overloaded
|
52
|
-
end
|
116
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
117
|
+
it_behaves_like 'it never works, without redis'
|
53
118
|
end
|
54
119
|
|
55
120
|
context 'when test_mode = :pass' do
|
56
121
|
before { Berater.test_mode = :pass }
|
57
122
|
|
58
|
-
it {
|
59
|
-
|
60
|
-
it 'always works' do
|
61
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
62
|
-
10.times { expect(limiter.limit).to be_a Berater::Lock }
|
63
|
-
end
|
123
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
124
|
+
it_behaves_like 'it always works, without redis'
|
64
125
|
end
|
65
126
|
|
66
127
|
context 'when test_mode = :fail' do
|
67
128
|
before { Berater.test_mode = :fail }
|
68
129
|
|
69
|
-
it {
|
130
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
131
|
+
it_behaves_like 'it never works, without redis'
|
70
132
|
|
71
|
-
it '
|
72
|
-
expect
|
133
|
+
it 'supports class specific logic' do
|
134
|
+
expect(subject.inhibited?).to be true
|
135
|
+
expect { subject.limit }.to raise_error(Berater::Inhibitor::Inhibited)
|
73
136
|
end
|
74
137
|
end
|
75
138
|
end
|
76
139
|
|
77
140
|
describe 'RateLimiter' do
|
78
|
-
|
141
|
+
subject { Berater::RateLimiter.new(:key, 1, :second) }
|
79
142
|
|
80
143
|
shared_examples 'a RateLimiter' do
|
81
|
-
it {
|
144
|
+
it { is_expected.to be_a Berater::RateLimiter }
|
82
145
|
|
83
146
|
it 'checks arguments' do
|
84
147
|
expect {
|
@@ -91,15 +154,12 @@ describe 'Berater.test_mode' do
|
|
91
154
|
before { Berater.test_mode = nil }
|
92
155
|
|
93
156
|
it_behaves_like 'a RateLimiter'
|
157
|
+
it_behaves_like 'it is not overloaded'
|
94
158
|
|
95
159
|
it 'works per usual' do
|
96
|
-
expect(
|
97
|
-
expect(
|
98
|
-
expect {
|
99
|
-
end
|
100
|
-
|
101
|
-
it 'yields per usual' do
|
102
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
160
|
+
expect(Berater::RateLimiter::LUA_SCRIPT).to receive(:eval).twice.and_call_original
|
161
|
+
expect(subject.limit).to be_a Berater::Lock
|
162
|
+
expect { subject.limit }.to be_overloaded
|
103
163
|
end
|
104
164
|
end
|
105
165
|
|
@@ -107,75 +167,57 @@ describe 'Berater.test_mode' do
|
|
107
167
|
before { Berater.test_mode = :pass }
|
108
168
|
|
109
169
|
it_behaves_like 'a RateLimiter'
|
110
|
-
|
111
|
-
it 'always works and without calling redis' do
|
112
|
-
expect(limiter.redis).not_to receive(:multi)
|
113
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
114
|
-
10.times { expect(limiter.limit).to be_a Berater::Lock }
|
115
|
-
end
|
170
|
+
it_behaves_like 'it always works, without redis'
|
116
171
|
end
|
117
172
|
|
118
173
|
context 'when test_mode = :fail' do
|
119
174
|
before { Berater.test_mode = :fail }
|
120
175
|
|
121
176
|
it_behaves_like 'a RateLimiter'
|
177
|
+
it_behaves_like 'it never works, without redis'
|
122
178
|
|
123
|
-
it '
|
124
|
-
expect(
|
125
|
-
expect {
|
179
|
+
it 'supports class specific logic' do
|
180
|
+
expect(subject.overrated?).to be true
|
181
|
+
expect { subject.limit }.to raise_error(Berater::RateLimiter::Overrated)
|
126
182
|
end
|
127
183
|
end
|
128
184
|
end
|
129
185
|
|
130
186
|
describe 'ConcurrencyLimiter' do
|
131
|
-
|
132
|
-
|
133
|
-
shared_examples 'a ConcurrencyLimiter' do
|
134
|
-
it { expect(limiter).to be_a Berater::ConcurrencyLimiter }
|
135
|
-
|
136
|
-
it 'checks arguments' do
|
137
|
-
expect {
|
138
|
-
Berater::ConcurrencyLimiter.new(:key, 1.0)
|
139
|
-
}.to raise_error(ArgumentError)
|
140
|
-
end
|
141
|
-
end
|
187
|
+
subject { Berater::ConcurrencyLimiter.new(:key, 1) }
|
142
188
|
|
143
189
|
context 'when test_mode = nil' do
|
144
190
|
before { Berater.test_mode = nil }
|
145
191
|
|
146
|
-
|
192
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
147
193
|
|
148
|
-
|
149
|
-
expect(limiter.redis).to receive(:eval).twice.and_call_original
|
150
|
-
expect(limiter.limit).to be_a Berater::Lock
|
151
|
-
expect { limiter.limit }.to be_overloaded
|
152
|
-
end
|
194
|
+
it_behaves_like 'it is not overloaded'
|
153
195
|
|
154
|
-
it '
|
155
|
-
expect
|
196
|
+
it 'works per usual' do
|
197
|
+
expect(Berater::ConcurrencyLimiter::LUA_SCRIPT).to receive(:eval).twice.and_call_original
|
198
|
+
expect(subject.limit).to be_a Berater::Lock
|
199
|
+
expect { subject.limit }.to be_overloaded
|
156
200
|
end
|
157
201
|
end
|
158
202
|
|
159
203
|
context 'when test_mode = :pass' do
|
160
204
|
before { Berater.test_mode = :pass }
|
161
205
|
|
162
|
-
|
206
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
163
207
|
|
164
|
-
it
|
165
|
-
expect(limiter.redis).not_to receive(:eval)
|
166
|
-
expect {|block| limiter.limit(&block) }.to yield_control
|
167
|
-
10.times { expect(limiter.limit).to be_a Berater::Lock }
|
168
|
-
end
|
208
|
+
it_behaves_like 'it always works, without redis'
|
169
209
|
end
|
170
210
|
|
171
211
|
context 'when test_mode = :fail' do
|
172
212
|
before { Berater.test_mode = :fail }
|
173
213
|
|
174
|
-
|
214
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
215
|
+
|
216
|
+
it_behaves_like 'it never works, without redis'
|
175
217
|
|
176
|
-
it '
|
177
|
-
expect(
|
178
|
-
expect {
|
218
|
+
it 'supports class specific logic' do
|
219
|
+
expect(subject.incapacitated?).to be true
|
220
|
+
expect { subject.limit }.to raise_error(Berater::ConcurrencyLimiter::Incapacitated)
|
179
221
|
end
|
180
222
|
end
|
181
223
|
end
|
data/spec/unlimiter_spec.rb
CHANGED
@@ -17,19 +17,13 @@ describe Berater::Unlimiter do
|
|
17
17
|
end
|
18
18
|
|
19
19
|
describe '#limit' do
|
20
|
-
|
20
|
+
subject { described_class.new }
|
21
21
|
|
22
|
-
it '
|
23
|
-
expect {|b| limiter.limit(&b) }.to yield_control
|
24
|
-
end
|
25
|
-
|
26
|
-
it 'works without a block' do
|
27
|
-
expect(limiter.limit).to be_a Berater::Lock
|
28
|
-
end
|
22
|
+
it_behaves_like 'it is not overloaded'
|
29
23
|
|
30
24
|
it 'is never overloaded' do
|
31
25
|
10.times do
|
32
|
-
expect {
|
26
|
+
expect { subject.limit }.not_to be_overloaded
|
33
27
|
end
|
34
28
|
end
|
35
29
|
end
|
data/spec/utils_spec.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
describe Berater::Utils do
|
2
|
+
using Berater::Utils
|
3
|
+
|
4
|
+
describe '.to_msec' do
|
5
|
+
def f(val)
|
6
|
+
(val * 10**3).to_i
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'works with integers' do
|
10
|
+
expect(0.to_msec).to be f(0)
|
11
|
+
expect(3.to_msec).to be f(3)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'works with floats' do
|
15
|
+
expect(0.1.to_msec).to be f(0.1)
|
16
|
+
expect(3.0.to_msec).to be f(3)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'truncates excessive precision' do
|
20
|
+
expect(0.123456.to_msec).to be 123
|
21
|
+
expect(123456.654321.to_msec).to be 123456654
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'works with symbols that are keywords' do
|
25
|
+
expect(:sec.to_msec).to be f(1)
|
26
|
+
expect(:second.to_msec).to be f(1)
|
27
|
+
expect(:seconds.to_msec).to be f(1)
|
28
|
+
|
29
|
+
expect(:min.to_msec).to be f(60)
|
30
|
+
expect(:minute.to_msec).to be f(60)
|
31
|
+
expect(:minutes.to_msec).to be f(60)
|
32
|
+
|
33
|
+
expect(:hour.to_msec).to be f(60 * 60)
|
34
|
+
expect(:hours.to_msec).to be f(60 * 60)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'works with strings that are keywords' do
|
38
|
+
expect('sec'.to_msec).to be f(1)
|
39
|
+
expect('second'.to_msec).to be f(1)
|
40
|
+
expect('seconds'.to_msec).to be f(1)
|
41
|
+
|
42
|
+
expect('min'.to_msec).to be f(60)
|
43
|
+
expect('minute'.to_msec).to be f(60)
|
44
|
+
expect('minutes'.to_msec).to be f(60)
|
45
|
+
|
46
|
+
expect('hour'.to_msec).to be f(60 * 60)
|
47
|
+
expect('hours'.to_msec).to be f(60 * 60)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'works with strings that are numeric' do
|
51
|
+
expect('0'.to_msec).to be f(0)
|
52
|
+
expect('3'.to_msec).to be f(3)
|
53
|
+
|
54
|
+
expect('0.1'.to_msec).to be f(0.1)
|
55
|
+
expect('3.0'.to_msec).to be f(3)
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'with erroneous values' do
|
59
|
+
def e(val)
|
60
|
+
expect { val.to_msec }.to raise_error(ArgumentError)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'rejects negative numbers' do
|
64
|
+
e(-1)
|
65
|
+
e(-1.2)
|
66
|
+
e('-1')
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'rejects bogus symbols and strings' do
|
70
|
+
e('abc')
|
71
|
+
e('1a')
|
72
|
+
e(:abc)
|
73
|
+
e(Float::INFINITY)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|