berater 0.1.4 → 0.6.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 +33 -35
- data/lib/berater/concurrency_limiter.rb +68 -110
- data/lib/berater/dsl.rb +68 -0
- data/lib/berater/inhibitor.rb +9 -10
- data/lib/berater/limiter.rb +80 -0
- data/lib/berater/lock.rb +26 -0
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +77 -54
- data/lib/berater/rspec.rb +14 -0
- data/lib/berater/rspec/matchers.rb +83 -0
- data/lib/berater/test_mode.rb +52 -0
- data/lib/berater/unlimiter.rb +11 -9
- data/lib/berater/utils.rb +46 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +43 -101
- data/spec/concurrency_limiter_spec.rb +168 -100
- data/spec/dsl_refinement_spec.rb +46 -0
- data/spec/dsl_spec.rb +72 -0
- data/spec/inhibitor_spec.rb +4 -18
- data/spec/limiter_spec.rb +71 -0
- data/spec/lua_script_spec.rb +97 -0
- data/spec/{matcher_spec.rb → matchers_spec.rb} +73 -5
- data/spec/rate_limiter_spec.rb +162 -99
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +206 -0
- data/spec/unlimiter_spec.rb +6 -37
- data/spec/utils_spec.rb +78 -0
- metadata +41 -8
- data/lib/berater/base_limiter.rb +0 -32
- data/spec/concurrency_lock_spec.rb +0 -92
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
|
@@ -0,0 +1,206 @@
|
|
1
|
+
describe Berater::TestMode, order: :defined do
|
2
|
+
let(:reset_test_mode) { true }
|
3
|
+
|
4
|
+
after do
|
5
|
+
Berater.test_mode = nil if reset_test_mode
|
6
|
+
end
|
7
|
+
|
8
|
+
context 'after test_mode.rb was required, but not used' do
|
9
|
+
let(:reset_test_mode) { false }
|
10
|
+
|
11
|
+
it 'has already been loaded by "berater/rspec", unfortunately' do
|
12
|
+
expect {
|
13
|
+
expect { Berater.test_mode }.to raise_error(NoMethodError)
|
14
|
+
}.to fail
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'defaults to off' do
|
18
|
+
expect(Berater.test_mode).to be nil
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'did not prepend .new yet' do
|
22
|
+
expect(Berater::Limiter.singleton_class.ancestors).not_to include(described_class)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'prepends when first turned on' do
|
26
|
+
Berater.test_mode = :pass
|
27
|
+
|
28
|
+
expect(Berater::Limiter.singleton_class.ancestors).to include(described_class)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'preserves the original functionality via super' do
|
32
|
+
expect { Berater::Limiter.new }.to raise_error(NotImplementedError)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '.test_mode' do
|
37
|
+
it 'can be turned on' do
|
38
|
+
Berater.test_mode = :pass
|
39
|
+
expect(Berater.test_mode).to be :pass
|
40
|
+
|
41
|
+
Berater.test_mode = :fail
|
42
|
+
expect(Berater.test_mode).to be :fail
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'can be turned off' do
|
46
|
+
Berater.test_mode = nil
|
47
|
+
expect(Berater.test_mode).to be nil
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'validates input' do
|
51
|
+
expect { Berater.test_mode = :foo }.to raise_error(ArgumentError)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
shared_examples 'it always works, without redis' do
|
56
|
+
before do
|
57
|
+
Berater.redis = nil
|
58
|
+
expect_any_instance_of(Berater::LuaScript).not_to receive(:eval)
|
59
|
+
end
|
60
|
+
|
61
|
+
it_behaves_like 'it is not overloaded'
|
62
|
+
|
63
|
+
it 'always works' do
|
64
|
+
10.times { subject.limit }
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
shared_examples 'it never works, without redis' do
|
69
|
+
before do
|
70
|
+
Berater.redis = nil
|
71
|
+
expect_any_instance_of(Berater::LuaScript).not_to receive(:eval)
|
72
|
+
end
|
73
|
+
|
74
|
+
it_behaves_like 'it is overloaded'
|
75
|
+
|
76
|
+
it 'never works' do
|
77
|
+
expect { subject }.to be_overloaded
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe 'Unlimiter' do
|
82
|
+
subject { Berater::Unlimiter.new }
|
83
|
+
|
84
|
+
context 'when test_mode = nil' do
|
85
|
+
before { Berater.test_mode = nil }
|
86
|
+
|
87
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
88
|
+
it_behaves_like 'it always works, without redis'
|
89
|
+
end
|
90
|
+
|
91
|
+
context 'when test_mode = :pass' do
|
92
|
+
before { Berater.test_mode = :pass }
|
93
|
+
|
94
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
95
|
+
it_behaves_like 'it always works, without redis'
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'when test_mode = :fail' do
|
99
|
+
before { Berater.test_mode = :fail }
|
100
|
+
|
101
|
+
it { is_expected.to be_a Berater::Unlimiter }
|
102
|
+
it_behaves_like 'it never works, without redis'
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe 'Inhibitor' do
|
107
|
+
subject { Berater::Inhibitor.new }
|
108
|
+
|
109
|
+
context 'when test_mode = nil' do
|
110
|
+
before { Berater.test_mode = nil }
|
111
|
+
|
112
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
113
|
+
it_behaves_like 'it never works, without redis'
|
114
|
+
end
|
115
|
+
|
116
|
+
context 'when test_mode = :pass' do
|
117
|
+
before { Berater.test_mode = :pass }
|
118
|
+
|
119
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
120
|
+
it_behaves_like 'it always works, without redis'
|
121
|
+
end
|
122
|
+
|
123
|
+
context 'when test_mode = :fail' do
|
124
|
+
before { Berater.test_mode = :fail }
|
125
|
+
|
126
|
+
it { is_expected.to be_a Berater::Inhibitor }
|
127
|
+
it_behaves_like 'it never works, without redis'
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
describe 'RateLimiter' do
|
132
|
+
subject { Berater::RateLimiter.new(:key, 1, :second) }
|
133
|
+
|
134
|
+
shared_examples 'a RateLimiter' do
|
135
|
+
it { is_expected.to be_a Berater::RateLimiter }
|
136
|
+
|
137
|
+
it 'checks arguments' do
|
138
|
+
expect {
|
139
|
+
Berater::RateLimiter.new(:key, 1)
|
140
|
+
}.to raise_error(ArgumentError)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
context 'when test_mode = nil' do
|
145
|
+
before { Berater.test_mode = nil }
|
146
|
+
|
147
|
+
it_behaves_like 'a RateLimiter'
|
148
|
+
it_behaves_like 'it is not overloaded'
|
149
|
+
|
150
|
+
it 'works per usual' do
|
151
|
+
expect(Berater::RateLimiter::LUA_SCRIPT).to receive(:eval).twice.and_call_original
|
152
|
+
expect(subject.limit).to be_a Berater::Lock
|
153
|
+
expect { subject.limit }.to be_overloaded
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
context 'when test_mode = :pass' do
|
158
|
+
before { Berater.test_mode = :pass }
|
159
|
+
|
160
|
+
it_behaves_like 'a RateLimiter'
|
161
|
+
it_behaves_like 'it always works, without redis'
|
162
|
+
end
|
163
|
+
|
164
|
+
context 'when test_mode = :fail' do
|
165
|
+
before { Berater.test_mode = :fail }
|
166
|
+
|
167
|
+
it_behaves_like 'a RateLimiter'
|
168
|
+
it_behaves_like 'it never works, without redis'
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
describe 'ConcurrencyLimiter' do
|
173
|
+
subject { Berater::ConcurrencyLimiter.new(:key, 1) }
|
174
|
+
|
175
|
+
context 'when test_mode = nil' do
|
176
|
+
before { Berater.test_mode = nil }
|
177
|
+
|
178
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
179
|
+
|
180
|
+
it_behaves_like 'it is not overloaded'
|
181
|
+
|
182
|
+
it 'works per usual' do
|
183
|
+
expect(Berater::ConcurrencyLimiter::LUA_SCRIPT).to receive(:eval).twice.and_call_original
|
184
|
+
expect(subject.limit).to be_a Berater::Lock
|
185
|
+
expect { subject.limit }.to be_overloaded
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
context 'when test_mode = :pass' do
|
190
|
+
before { Berater.test_mode = :pass }
|
191
|
+
|
192
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
193
|
+
|
194
|
+
it_behaves_like 'it always works, without redis'
|
195
|
+
end
|
196
|
+
|
197
|
+
context 'when test_mode = :fail' do
|
198
|
+
before { Berater.test_mode = :fail }
|
199
|
+
|
200
|
+
it { is_expected.to be_a Berater::ConcurrencyLimiter }
|
201
|
+
|
202
|
+
it_behaves_like 'it never works, without redis'
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
end
|
data/spec/unlimiter_spec.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
describe Berater::Unlimiter do
|
2
|
-
|
2
|
+
it_behaves_like 'a limiter', described_class.new
|
3
3
|
|
4
4
|
describe '.new' do
|
5
5
|
it 'initializes without any arguments or options' do
|
@@ -7,55 +7,24 @@ describe Berater::Unlimiter do
|
|
7
7
|
end
|
8
8
|
|
9
9
|
it 'initializes with any arguments and options' do
|
10
|
-
expect(described_class.new(:abc, x: 123)).to be_a described_class
|
10
|
+
expect(described_class.new(:abc, :def, x: 123)).to be_a described_class
|
11
11
|
end
|
12
12
|
|
13
13
|
it 'has default values' do
|
14
|
-
expect(described_class.new.key).to
|
14
|
+
expect(described_class.new.key).to be :unlimiter
|
15
15
|
expect(described_class.new.redis).to be Berater.redis
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
-
describe '.limit' do
|
20
|
-
it 'works' do
|
21
|
-
expect(described_class.limit).to be_nil
|
22
|
-
end
|
23
|
-
|
24
|
-
it 'yields' do
|
25
|
-
expect {|b| described_class.limit(&b) }.to yield_control
|
26
|
-
end
|
27
|
-
|
28
|
-
it 'is never overloaded' do
|
29
|
-
10.times do
|
30
|
-
expect { described_class.limit }.not_to be_overloaded
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
it 'works with any arguments or options' do
|
35
|
-
expect(described_class.limit(:abc, x: 123)).to be_nil
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
19
|
describe '#limit' do
|
40
|
-
|
20
|
+
subject { described_class.new }
|
41
21
|
|
42
|
-
it '
|
43
|
-
expect(limiter.limit).to be_nil
|
44
|
-
end
|
45
|
-
|
46
|
-
it 'yields' do
|
47
|
-
expect {|b| limiter.limit(&b) }.to yield_control
|
48
|
-
end
|
22
|
+
it_behaves_like 'it is not overloaded'
|
49
23
|
|
50
24
|
it 'is never overloaded' do
|
51
25
|
10.times do
|
52
|
-
expect {
|
26
|
+
expect { subject.limit }.not_to be_overloaded
|
53
27
|
end
|
54
28
|
end
|
55
|
-
|
56
|
-
it 'works with any arguments or options' do
|
57
|
-
expect(limiter.limit(x: 123)).to be_nil
|
58
|
-
end
|
59
29
|
end
|
60
|
-
|
61
30
|
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
|