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.
@@ -1,12 +1,13 @@
1
1
  describe Berater::RateLimiter do
2
- it_behaves_like 'a limiter', Berater.new(:key, :rate, 3, :second)
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.count).to eq 1
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 '#count' do
19
- def expect_count(count)
20
- limiter = described_class.new(:key, count, :second)
21
- expect(limiter.count).to eq count
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 { expect_count(0) }
25
- it { expect_count(1) }
26
- it { expect_count(100) }
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 expect_bad_count(count)
31
+ def expect_bad_capacity(capacity)
30
32
  expect do
31
- described_class.new(:key, count, :second)
33
+ described_class.new(:key, capacity, :second)
32
34
  end.to raise_error ArgumentError
33
35
  end
34
36
 
35
- it { expect_bad_count(0.5) }
36
- it { expect_bad_count(-1) }
37
- it { expect_bad_count('1') }
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
- def expect_interval(interval, expected)
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
- context 'with symbols' do
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
- it { expect_interval(:min, :minute) }
60
- it { expect_interval(:minute, :minute) }
61
- it { expect_interval(:minutes, :minute) }
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
- context 'with strings' do
68
- it { expect_interval('sec', :second) }
69
- it { expect_interval('minute', :minute) }
70
- it { expect_interval('hours', :hour) }
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
- it { expect_bad_interval(-1) }
81
- it { expect_bad_interval(:secondz) }
82
- it { expect_bad_interval('huor') }
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 be_overrated
79
+ expect(limiter).to be_overloaded
113
80
  end
114
81
 
115
- it 'limit resets over time' do
82
+ it 'resets limit over time' do
116
83
  3.times { limiter.limit }
117
- expect(limiter).to be_overrated
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 be_overrated
89
+ expect(limiter).to be_overloaded
124
90
  end
125
- end
126
91
 
127
- context 'with same key, different limiters' do
128
- let(:limiter_one) { described_class.new(:key, 1, :second) }
129
- let(:limiter_two) { described_class.new(:key, 1, :second) }
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
- it 'works as expected' do
132
- expect(limiter_one.limit).not_to be_overrated
116
+ 2.times { limiter.limit }
117
+ end
118
+ end
133
119
 
134
- expect(limiter_one).to be_overrated
135
- expect(limiter_two).to be_overrated
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
- context 'with different keys, different limiters' do
140
- let(:limiter_one) { described_class.new(:one, 1, :second) }
141
- let(:limiter_two) { described_class.new(:two, 2, :second) }
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
- it 'works as expected' do
144
- expect(limiter_one.limit).not_to be_overrated
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
- expect(limiter_one).to be_overrated
148
- expect(limiter_two.limit).not_to be_overrated
246
+ Timecop.freeze(30)
149
247
 
150
- expect(limiter_one).to be_overrated
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(count, interval, expected)
253
+ def check(capacity, interval, expected)
157
254
  expect(
158
- described_class.new(:key, count, interval).to_s
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/)
@@ -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