berater 0.4.0 → 0.7.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 +4 -4
- data/lib/berater.rb +29 -37
- data/lib/berater/concurrency_limiter.rb +53 -48
- data/lib/berater/dsl.rb +21 -10
- data/lib/berater/inhibitor.rb +3 -5
- data/lib/berater/limiter.rb +74 -4
- data/lib/berater/lock.rb +4 -14
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +62 -90
- data/lib/berater/rspec.rb +3 -1
- data/lib/berater/rspec/matchers.rb +43 -42
- data/lib/berater/test_mode.rb +14 -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 +35 -72
- data/spec/concurrency_limiter_spec.rb +168 -66
- data/spec/dsl_refinement_spec.rb +34 -0
- data/spec/dsl_spec.rb +60 -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 +64 -60
- data/spec/rate_limiter_spec.rb +183 -96
- data/spec/riddle_spec.rb +106 -0
- data/spec/test_mode_spec.rb +83 -124
- data/spec/unlimiter_spec.rb +3 -9
- data/spec/utils_spec.rb +78 -0
- metadata +31 -3
data/spec/rate_limiter_spec.rb
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
describe Berater::RateLimiter do
|
2
|
-
it_behaves_like 'a limiter',
|
2
|
+
it_behaves_like 'a limiter', described_class.new(:key, 3, :second)
|
3
|
+
it_behaves_like 'a limiter', described_class.new(:key, 3.5, :second)
|
3
4
|
|
4
5
|
describe '.new' do
|
5
6
|
let(:limiter) { described_class.new(:key, 1, :second) }
|
6
7
|
|
7
8
|
it 'initializes' do
|
8
9
|
expect(limiter.key).to be :key
|
9
|
-
expect(limiter.
|
10
|
+
expect(limiter.capacity).to eq 1
|
10
11
|
expect(limiter.interval).to eq :second
|
11
12
|
end
|
12
13
|
|
@@ -15,82 +16,48 @@ describe Berater::RateLimiter do
|
|
15
16
|
end
|
16
17
|
end
|
17
18
|
|
18
|
-
describe '#
|
19
|
-
def
|
20
|
-
limiter = described_class.new(:key,
|
21
|
-
expect(limiter.
|
19
|
+
describe '#capacity' do
|
20
|
+
def expect_capacity(capacity)
|
21
|
+
limiter = described_class.new(:key, capacity, :second)
|
22
|
+
expect(limiter.capacity).to eq capacity
|
22
23
|
end
|
23
24
|
|
24
|
-
it {
|
25
|
-
it {
|
26
|
-
it {
|
25
|
+
it { expect_capacity(0) }
|
26
|
+
it { expect_capacity(1) }
|
27
|
+
it { expect_capacity(1.5) }
|
28
|
+
it { expect_capacity(100) }
|
27
29
|
|
28
30
|
context 'with erroneous values' do
|
29
|
-
def
|
31
|
+
def expect_bad_capacity(capacity)
|
30
32
|
expect do
|
31
|
-
described_class.new(:key,
|
33
|
+
described_class.new(:key, capacity, :second)
|
32
34
|
end.to raise_error ArgumentError
|
33
35
|
end
|
34
36
|
|
35
|
-
it {
|
36
|
-
it {
|
37
|
-
it {
|
38
|
-
it { expect_bad_count(:one) }
|
37
|
+
it { expect_bad_capacity(-1) }
|
38
|
+
it { expect_bad_capacity('1') }
|
39
|
+
it { expect_bad_capacity(:one) }
|
39
40
|
end
|
40
41
|
end
|
41
42
|
|
42
43
|
describe '#interval' do
|
43
|
-
|
44
|
-
limiter = described_class.new(:key, 1, interval)
|
45
|
-
expect(limiter.interval).to eq expected
|
46
|
-
end
|
47
|
-
|
48
|
-
context 'with ints' do
|
49
|
-
it { expect_interval(0, 0) }
|
50
|
-
it { expect_interval(1, 1) }
|
51
|
-
it { expect_interval(33, 33) }
|
52
|
-
end
|
44
|
+
# see spec/utils_spec.rb for more
|
53
45
|
|
54
|
-
|
55
|
-
it { expect_interval(:sec, :second) }
|
56
|
-
it { expect_interval(:second, :second) }
|
57
|
-
it { expect_interval(:seconds, :second) }
|
46
|
+
subject { described_class.new(:key, 1, :second) }
|
58
47
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
it { expect_interval(:hour, :hour) }
|
64
|
-
it { expect_interval(:hours, :hour) }
|
48
|
+
it 'saves the interval in original and millisecond format' do
|
49
|
+
expect(subject.interval).to be :second
|
50
|
+
expect(subject.instance_variable_get(:@interval_msec)).to be 10**3
|
65
51
|
end
|
66
52
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
end
|
72
|
-
|
73
|
-
context 'with erroneous values' do
|
74
|
-
def expect_bad_interval(interval)
|
75
|
-
expect do
|
76
|
-
described_class.new(:key, 1, interval)
|
77
|
-
end.to raise_error(ArgumentError)
|
78
|
-
end
|
53
|
+
it 'must be > 0' do
|
54
|
+
expect {
|
55
|
+
described_class.new(:key, 1, 0)
|
56
|
+
}.to raise_error(ArgumentError)
|
79
57
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
end
|
84
|
-
|
85
|
-
context 'interprets values' do
|
86
|
-
def expect_sec(interval, expected)
|
87
|
-
limiter = described_class.new(:key, 1, interval)
|
88
|
-
expect(limiter.instance_variable_get(:@interval_sec)).to eq expected
|
89
|
-
end
|
90
|
-
|
91
|
-
it { expect_sec(:second, 1) }
|
92
|
-
it { expect_sec(:minute, 60) }
|
93
|
-
it { expect_sec(:hour, 3600) }
|
58
|
+
expect {
|
59
|
+
described_class.new(:key, 1, -1)
|
60
|
+
}.to raise_error(ArgumentError)
|
94
61
|
end
|
95
62
|
end
|
96
63
|
|
@@ -109,53 +76,183 @@ describe Berater::RateLimiter do
|
|
109
76
|
it 'limits excessive calls' do
|
110
77
|
3.times { limiter.limit }
|
111
78
|
|
112
|
-
expect(limiter).to
|
79
|
+
expect(limiter).to be_overloaded
|
113
80
|
end
|
114
81
|
|
115
|
-
it 'limit
|
82
|
+
it 'resets limit over time' do
|
116
83
|
3.times { limiter.limit }
|
117
|
-
expect(limiter).to
|
84
|
+
expect(limiter).to be_overloaded
|
118
85
|
|
119
|
-
# travel forward a second
|
120
86
|
Timecop.freeze(1)
|
121
87
|
|
122
88
|
3.times { limiter.limit }
|
123
|
-
expect(limiter).to
|
89
|
+
expect(limiter).to be_overloaded
|
124
90
|
end
|
125
|
-
end
|
126
91
|
|
127
|
-
|
128
|
-
|
129
|
-
|
92
|
+
context 'with millisecond precision' do
|
93
|
+
it 'resets limit over time' do
|
94
|
+
3.times { limiter.limit }
|
95
|
+
expect(limiter).to be_overloaded
|
96
|
+
|
97
|
+
# travel forward to just before the count decrements
|
98
|
+
Timecop.freeze(0.333)
|
99
|
+
expect(limiter).to be_overloaded
|
100
|
+
|
101
|
+
# traveling one more millisecond will decrement the count
|
102
|
+
Timecop.freeze(0.001)
|
103
|
+
limiter.limit
|
104
|
+
expect(limiter).to be_overloaded
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'works when drip rate is < 1 per millisecond' do
|
108
|
+
limiter = described_class.new(:key, 2_000, :second)
|
109
|
+
|
110
|
+
limiter.capacity.times { limiter.limit }
|
111
|
+
expect(limiter).to be_overloaded
|
112
|
+
|
113
|
+
Timecop.freeze(0.001)
|
114
|
+
expect(limiter).not_to be_overloaded
|
130
115
|
|
131
|
-
|
132
|
-
|
116
|
+
2.times { limiter.limit }
|
117
|
+
end
|
118
|
+
end
|
133
119
|
|
134
|
-
|
135
|
-
|
120
|
+
context 'when capacity is a Float' do
|
121
|
+
let(:limiter) { described_class.new(:key, 1.5, :second) }
|
122
|
+
|
123
|
+
it 'still works' do
|
124
|
+
limiter.limit
|
125
|
+
expect(limiter).not_to be_overloaded
|
126
|
+
|
127
|
+
expect { limiter.limit }.to be_overloaded
|
128
|
+
|
129
|
+
limiter.limit(cost: 0.5)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
it 'accepts a dynamic capacity' do
|
134
|
+
limiter = described_class.new(:key, 1, :second)
|
135
|
+
|
136
|
+
expect { limiter.limit(capacity: 0) }.to be_overloaded
|
137
|
+
5.times { limiter.limit(capacity: 10) }
|
138
|
+
expect { limiter }.to be_overloaded
|
139
|
+
end
|
140
|
+
|
141
|
+
context 'works with cost parameter' do
|
142
|
+
it { expect { limiter.limit(cost: 4) }.to be_overloaded }
|
143
|
+
|
144
|
+
it 'works within limit' do
|
145
|
+
limiter.limit(cost: 3)
|
146
|
+
expect { limiter.limit }.to be_overloaded
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'resets over time' do
|
150
|
+
limiter.limit(cost: 3)
|
151
|
+
expect(limiter).to be_overloaded
|
152
|
+
|
153
|
+
Timecop.freeze(1)
|
154
|
+
expect(limiter).not_to be_overloaded
|
155
|
+
end
|
156
|
+
|
157
|
+
context 'when cost is a Float' do
|
158
|
+
it 'still works' do
|
159
|
+
2.times { limiter.limit(cost: 1.5) }
|
160
|
+
expect(limiter).to be_overloaded
|
161
|
+
end
|
162
|
+
|
163
|
+
it 'calculates contention correctly' do
|
164
|
+
# note: Redis must return Floats as strings to maintain precision
|
165
|
+
lock = limiter.limit(cost: 1.5)
|
166
|
+
expect(lock.contention).to be 1.5
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
context 'with clock skew' do
|
172
|
+
let(:limiter) { described_class.new(:key, 10, :second) }
|
173
|
+
|
174
|
+
it 'works skewing backward' do
|
175
|
+
limiter.limit(cost: 9)
|
176
|
+
|
177
|
+
Timecop.freeze(-0.1) do
|
178
|
+
limiter.limit
|
179
|
+
expect(limiter).to be_overloaded
|
180
|
+
end
|
181
|
+
|
182
|
+
expect(limiter).to be_overloaded
|
183
|
+
|
184
|
+
Timecop.freeze(0.1)
|
185
|
+
limiter.limit
|
186
|
+
expect(limiter).to be_overloaded
|
187
|
+
end
|
188
|
+
|
189
|
+
it 'works skewing forward' do
|
190
|
+
limiter.limit
|
191
|
+
|
192
|
+
Timecop.freeze(0.1) do
|
193
|
+
# one drip later
|
194
|
+
limiter.limit(cost: 10)
|
195
|
+
expect(limiter).to be_overloaded
|
196
|
+
end
|
197
|
+
|
198
|
+
expect(limiter).to be_overloaded
|
199
|
+
|
200
|
+
Timecop.freeze(0.1)
|
201
|
+
expect(limiter).to be_overloaded
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
context 'with same key, different limiters' do
|
206
|
+
let(:limiter_one) { described_class.new(:key, 1, :second) }
|
207
|
+
let(:limiter_two) { described_class.new(:key, 1, :second) }
|
208
|
+
|
209
|
+
it 'works as expected' do
|
210
|
+
expect(limiter_one.limit).not_to be_overloaded
|
211
|
+
|
212
|
+
expect(limiter_one).to be_overloaded
|
213
|
+
expect(limiter_two).to be_overloaded
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
context 'with different keys, different limiters' do
|
218
|
+
let(:limiter_one) { described_class.new(:one, 1, :second) }
|
219
|
+
let(:limiter_two) { described_class.new(:two, 2, :second) }
|
220
|
+
|
221
|
+
it 'works as expected' do
|
222
|
+
expect(limiter_one.limit).not_to be_overloaded
|
223
|
+
expect(limiter_two.limit).not_to be_overloaded
|
224
|
+
|
225
|
+
expect(limiter_one).to be_overloaded
|
226
|
+
expect(limiter_two.limit).not_to be_overloaded
|
227
|
+
|
228
|
+
expect(limiter_one).to be_overloaded
|
229
|
+
expect(limiter_two).to be_overloaded
|
230
|
+
end
|
136
231
|
end
|
137
232
|
end
|
138
233
|
|
139
|
-
|
140
|
-
let(:
|
141
|
-
|
234
|
+
describe '#utilization' do
|
235
|
+
let(:limiter) { described_class.new(:key, 10, :minute) }
|
236
|
+
|
237
|
+
it do
|
238
|
+
expect(limiter.utilization).to be 0.0
|
239
|
+
|
240
|
+
2.times { limiter.limit }
|
241
|
+
expect(limiter.utilization).to be 0.2
|
142
242
|
|
143
|
-
|
144
|
-
expect(
|
145
|
-
expect(limiter_two.limit).not_to be_overrated
|
243
|
+
8.times { limiter.limit }
|
244
|
+
expect(limiter.utilization).to be 1.0
|
146
245
|
|
147
|
-
|
148
|
-
expect(limiter_two.limit).not_to be_overrated
|
246
|
+
Timecop.freeze(30)
|
149
247
|
|
150
|
-
expect(
|
151
|
-
expect(limiter_two).to be_overrated
|
248
|
+
expect(limiter.utilization).to be 0.5
|
152
249
|
end
|
153
250
|
end
|
154
251
|
|
155
252
|
describe '#to_s' do
|
156
|
-
def check(
|
253
|
+
def check(capacity, interval, expected)
|
157
254
|
expect(
|
158
|
-
described_class.new(:key,
|
255
|
+
described_class.new(:key, capacity, interval).to_s
|
159
256
|
).to match(expected)
|
160
257
|
end
|
161
258
|
|
@@ -171,16 +268,6 @@ describe Berater::RateLimiter do
|
|
171
268
|
check(1, 'hour', /1 per hour/)
|
172
269
|
end
|
173
270
|
|
174
|
-
it 'normalizes' do
|
175
|
-
check(1, :sec, /1 per second/)
|
176
|
-
check(1, :seconds, /1 per second/)
|
177
|
-
|
178
|
-
check(1, :min, /1 per minute/)
|
179
|
-
check(1, :minutes, /1 per minute/)
|
180
|
-
|
181
|
-
check(1, :hours, /1 per hour/)
|
182
|
-
end
|
183
|
-
|
184
271
|
it 'works with integers' do
|
185
272
|
check(1, 1, /1 every second/)
|
186
273
|
check(1, 2, /1 every 2 seconds/)
|
data/spec/riddle_spec.rb
ADDED
@@ -0,0 +1,106 @@
|
|
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
|
+
lock = limiter.limit
|
46
|
+
yield if block_given?
|
47
|
+
ensure
|
48
|
+
# decrement counter
|
49
|
+
if lock
|
50
|
+
key = limiter.send(:cache_key, :key)
|
51
|
+
count, ts = limiter.redis.get(key).split ';'
|
52
|
+
limiter.redis.set(key, "#{count.to_f - 1};#{ts}")
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
describe 'a RateLimiter-derived concurrency limiter' do
|
59
|
+
def limit(capacity = 1, timeout: nil, &block)
|
60
|
+
ConcurrenyRiddler.limit(capacity, timeout: timeout, &block)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'works' do
|
64
|
+
expect(limit { 123 }).to eq 123
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'respects limits' do
|
68
|
+
limit do
|
69
|
+
# a second, simultaneous request isn't allowed
|
70
|
+
expect { limit }.to be_overloaded
|
71
|
+
end
|
72
|
+
|
73
|
+
# but now it'll work
|
74
|
+
limit
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'resets over time' do
|
78
|
+
limit(timeout: 1) do
|
79
|
+
expect { limit }.to be_overloaded
|
80
|
+
|
81
|
+
# ...wait for it
|
82
|
+
Timecop.freeze(10)
|
83
|
+
|
84
|
+
limit(timeout: 1)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
it "has no memory of the order, so timeouts don't work quite right" do
|
89
|
+
limit(2, timeout: 1) do
|
90
|
+
Timecop.freeze(0.5)
|
91
|
+
|
92
|
+
limit(2, timeout: 1) do
|
93
|
+
# this is where the masquerading breaks. the first lock is still
|
94
|
+
# being held and within it's timeout limit, however the RaterLimiter
|
95
|
+
# decremented the count internally since enough time has passed.
|
96
|
+
# This next call *should* fail, but doesn't.
|
97
|
+
|
98
|
+
expect {
|
99
|
+
expect { limit(2, timeout: 1) }.to be_overloaded
|
100
|
+
}.to fail
|
101
|
+
|
102
|
+
# ...close, but not quite!
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|