berater 0.10.0 → 0.12.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/limiter.rb +26 -5
- data/lib/berater/lua_script.rb +1 -0
- data/lib/berater/middleware/fail_open.rb +43 -0
- data/lib/berater/middleware/load_shedder.rb +36 -0
- data/lib/berater/middleware/statsd.rb +82 -0
- data/lib/berater/middleware.rb +7 -0
- data/lib/berater/version.rb +1 -1
- data/lib/berater.rb +1 -0
- data/spec/concurrency_limiter_spec.rb +2 -1
- data/spec/limiter_spec.rb +40 -0
- data/spec/middleware/fail_open_spec.rb +196 -0
- data/spec/middleware/load_shedder_spec.rb +150 -0
- data/spec/middleware/statsd_spec.rb +227 -0
- data/spec/middleware_spec.rb +10 -0
- data/spec/rate_limiter_spec.rb +3 -2
- metadata +37 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 72cc5fcf9c68fff6dd3988ff85897b38d6fdcff21c0e6f94d3ec098ca8b35fc7
|
4
|
+
data.tar.gz: 49144f0dee2d7197740d39b9da365f9d959c730b7ece1ae761948ec9263ada75
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 401a193c358a80098f1250053983d8f1a362cd14649156a036deedeb461fd70202743764d375b04a2b6b4fb08c795ec61d013e1be342626a2c8aad7dab2cb259
|
7
|
+
data.tar.gz: 55498f8b1a6bd70a64ed9053b80bc9c95b0d60043f073c302d48bb79b51677d7a3c235c07f35cc51dacec067d0c2cc8dcf972e37fb9e9baae126f69fa8aacb4f
|
data/lib/berater/limiter.rb
CHANGED
@@ -7,12 +7,12 @@ module Berater
|
|
7
7
|
options[:redis] || Berater.redis
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit(
|
11
|
-
capacity ||= @capacity
|
12
|
-
|
10
|
+
def limit(**opts, &block)
|
11
|
+
opts[:capacity] ||= @capacity
|
12
|
+
opts[:cost] ||= 1
|
13
13
|
|
14
|
-
Berater.middleware.call(self,
|
15
|
-
|
14
|
+
lock = Berater.middleware.call(self, **opts) do |limiter, **opts|
|
15
|
+
limiter.inner_limit(**opts)
|
16
16
|
end
|
17
17
|
|
18
18
|
if block_given?
|
@@ -27,10 +27,24 @@ module Berater
|
|
27
27
|
end
|
28
28
|
|
29
29
|
protected def inner_limit(capacity:, cost:)
|
30
|
+
if capacity.is_a?(String)
|
31
|
+
# try casting
|
32
|
+
begin
|
33
|
+
capacity = Float(capacity)
|
34
|
+
rescue ArgumentError; end
|
35
|
+
end
|
36
|
+
|
30
37
|
unless capacity.is_a?(Numeric) && capacity >= 0
|
31
38
|
raise ArgumentError, "invalid capacity: #{capacity}"
|
32
39
|
end
|
33
40
|
|
41
|
+
if cost.is_a?(String)
|
42
|
+
# try casting
|
43
|
+
begin
|
44
|
+
cost = Float(cost)
|
45
|
+
rescue ArgumentError; end
|
46
|
+
end
|
47
|
+
|
34
48
|
unless cost.is_a?(Numeric) && cost >= 0 && cost < Float::INFINITY
|
35
49
|
raise ArgumentError, "invalid cost: #{cost}"
|
36
50
|
end
|
@@ -76,6 +90,13 @@ module Berater
|
|
76
90
|
end
|
77
91
|
|
78
92
|
def capacity=(capacity)
|
93
|
+
if capacity.is_a?(String)
|
94
|
+
# try casting
|
95
|
+
begin
|
96
|
+
capacity = Float(capacity)
|
97
|
+
rescue TypeError, ArgumentError; end
|
98
|
+
end
|
99
|
+
|
79
100
|
unless capacity.is_a?(Numeric)
|
80
101
|
raise ArgumentError, "expected Numeric, found #{capacity.class}"
|
81
102
|
end
|
data/lib/berater/lua_script.rb
CHANGED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Berater
|
4
|
+
module Middleware
|
5
|
+
class FailOpen
|
6
|
+
ERRORS = Set[
|
7
|
+
Redis::BaseConnectionError,
|
8
|
+
]
|
9
|
+
|
10
|
+
def initialize(errors: nil, on_fail: nil)
|
11
|
+
@errors = errors || ERRORS
|
12
|
+
@on_fail = on_fail
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(*, **opts)
|
16
|
+
yield.tap do |lock|
|
17
|
+
return lock unless lock&.is_a?(Berater::Lock)
|
18
|
+
|
19
|
+
# wrap lock.release so it fails open
|
20
|
+
|
21
|
+
# save reference to original function
|
22
|
+
release_fn = lock.method(:release)
|
23
|
+
|
24
|
+
# make bound variables accessible to block
|
25
|
+
errors = @errors
|
26
|
+
on_fail = @on_fail
|
27
|
+
|
28
|
+
lock.define_singleton_method(:release) do
|
29
|
+
release_fn.call
|
30
|
+
rescue *errors => e
|
31
|
+
on_fail&.call(e)
|
32
|
+
false
|
33
|
+
end
|
34
|
+
end
|
35
|
+
rescue *@errors => e
|
36
|
+
@on_fail&.call(e)
|
37
|
+
|
38
|
+
# fail open by faking a lock
|
39
|
+
Berater::Lock.new(opts[:capacity], -1)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Berater
|
2
|
+
module Middleware
|
3
|
+
class LoadShedder
|
4
|
+
PRIORITY_RANGE = 1..5
|
5
|
+
|
6
|
+
def initialize(default_priority: nil)
|
7
|
+
@default_priority = default_priority
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(*args, **opts)
|
11
|
+
if priority = opts.delete(:priority) || @default_priority
|
12
|
+
opts[:capacity] = adjust_capacity(opts[:capacity], priority)
|
13
|
+
end
|
14
|
+
|
15
|
+
yield *args, **opts
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def adjust_capacity(capacity, priority)
|
21
|
+
if priority.is_a?(String)
|
22
|
+
# try casting
|
23
|
+
priority = Float(priority) rescue nil
|
24
|
+
end
|
25
|
+
|
26
|
+
unless PRIORITY_RANGE.include?(priority)
|
27
|
+
return capacity
|
28
|
+
end
|
29
|
+
|
30
|
+
# priority 1 stays at 100%, 2 scales down to 90%, 5 to 60%
|
31
|
+
factor = 1 - (priority - 1) * 0.1
|
32
|
+
(capacity * factor).floor
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Berater
|
2
|
+
module Middleware
|
3
|
+
class Statsd
|
4
|
+
def initialize(client, tags: {})
|
5
|
+
@client = client
|
6
|
+
@tags = tags
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(limiter, **opts)
|
10
|
+
duration = -Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
11
|
+
lock = yield
|
12
|
+
rescue Exception => error
|
13
|
+
# note exception and propagate
|
14
|
+
raise
|
15
|
+
ensure
|
16
|
+
duration += Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
17
|
+
duration = (duration * 1_000).round(2) # milliseconds
|
18
|
+
|
19
|
+
tags = build_tags(limiter, opts)
|
20
|
+
|
21
|
+
@client.timing(
|
22
|
+
'berater.limiter.limit',
|
23
|
+
duration,
|
24
|
+
tags: tags.merge(overloaded: !lock),
|
25
|
+
)
|
26
|
+
|
27
|
+
@client.gauge(
|
28
|
+
'berater.limiter.capacity',
|
29
|
+
limiter.capacity,
|
30
|
+
tags: tags,
|
31
|
+
)
|
32
|
+
|
33
|
+
if lock && lock.contention > 0 # not a failsafe lock
|
34
|
+
@client.gauge(
|
35
|
+
'berater.lock.capacity',
|
36
|
+
lock.capacity,
|
37
|
+
tags: tags,
|
38
|
+
)
|
39
|
+
@client.gauge(
|
40
|
+
'berater.limiter.contention',
|
41
|
+
lock.contention,
|
42
|
+
tags: tags,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
if error
|
47
|
+
if error.is_a?(Berater::Overloaded)
|
48
|
+
# overloaded, so contention >= capacity
|
49
|
+
@client.gauge(
|
50
|
+
'berater.limiter.contention',
|
51
|
+
limiter.capacity,
|
52
|
+
tags: tags,
|
53
|
+
)
|
54
|
+
else
|
55
|
+
@client.increment(
|
56
|
+
'berater.limiter.error',
|
57
|
+
tags: tags.merge(type: error.class.to_s)
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def build_tags(limiter, opts)
|
66
|
+
tags = {
|
67
|
+
key: limiter.key,
|
68
|
+
limiter: limiter.class.to_s.split(':')[-1],
|
69
|
+
}
|
70
|
+
|
71
|
+
# append custom tags
|
72
|
+
if @tags.respond_to?(:call)
|
73
|
+
tags.merge!(@tags.call(limiter, **opts) || {})
|
74
|
+
else
|
75
|
+
tags.merge!(@tags)
|
76
|
+
end
|
77
|
+
|
78
|
+
tags.merge!(opts.fetch(:tags, {}))
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
data/lib/berater/version.rb
CHANGED
data/lib/berater.rb
CHANGED
@@ -24,6 +24,7 @@ describe Berater::ConcurrencyLimiter do
|
|
24
24
|
it { expect_capacity(0) }
|
25
25
|
it { expect_capacity(1) }
|
26
26
|
it { expect_capacity(1.5) }
|
27
|
+
it { expect_capacity('1.5') }
|
27
28
|
it { expect_capacity(10_000) }
|
28
29
|
|
29
30
|
context 'with erroneous values' do
|
@@ -34,7 +35,7 @@ describe Berater::ConcurrencyLimiter do
|
|
34
35
|
end
|
35
36
|
|
36
37
|
it { expect_bad_capacity(-1) }
|
37
|
-
it { expect_bad_capacity('
|
38
|
+
it { expect_bad_capacity('abc') }
|
38
39
|
it { expect_bad_capacity(:one) }
|
39
40
|
it { expect_bad_capacity(Float::INFINITY) }
|
40
41
|
end
|
data/spec/limiter_spec.rb
CHANGED
@@ -14,6 +14,34 @@ describe Berater::Limiter do
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
+
describe '#capacity=' do
|
18
|
+
subject do
|
19
|
+
Berater::RateLimiter.new(:key, capacity, :second).capacity
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'when capacity is numeric' do
|
23
|
+
let(:capacity) { 3.5 }
|
24
|
+
|
25
|
+
it { is_expected.to be capacity }
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'when capacity is a stringified numeric' do
|
29
|
+
let(:capacity) { '3.5' }
|
30
|
+
|
31
|
+
it 'casts the value' do
|
32
|
+
is_expected.to be capacity.to_f
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context 'when capacity is a bogus value' do
|
37
|
+
let(:capacity) { :abc }
|
38
|
+
|
39
|
+
it 'raises' do
|
40
|
+
expect { subject }.to raise_error(ArgumentError)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
17
45
|
describe '#limit' do
|
18
46
|
subject { Berater::Unlimiter.new }
|
19
47
|
|
@@ -29,6 +57,12 @@ describe Berater::Limiter do
|
|
29
57
|
subject.limit(capacity: 'abc')
|
30
58
|
}.to raise_error(ArgumentError)
|
31
59
|
end
|
60
|
+
|
61
|
+
it 'handles stringified numerics gracefully' do
|
62
|
+
is_expected.to receive(:acquire_lock).with(3.5, anything)
|
63
|
+
|
64
|
+
subject.limit(capacity: '3.5')
|
65
|
+
end
|
32
66
|
end
|
33
67
|
|
34
68
|
context 'with a cost parameter' do
|
@@ -51,6 +85,12 @@ describe Berater::Limiter do
|
|
51
85
|
subject.limit(cost: Float::INFINITY)
|
52
86
|
}.to raise_error(ArgumentError)
|
53
87
|
end
|
88
|
+
|
89
|
+
it 'handles stringified numerics gracefully' do
|
90
|
+
is_expected.to receive(:acquire_lock).with(anything, 2.5)
|
91
|
+
|
92
|
+
subject.limit(cost: '2.5')
|
93
|
+
end
|
54
94
|
end
|
55
95
|
|
56
96
|
context 'when Berater.redis is nil' do
|
@@ -0,0 +1,196 @@
|
|
1
|
+
describe Berater::Middleware::FailOpen do
|
2
|
+
let(:limiter) { Berater::Unlimiter.new }
|
3
|
+
let(:lock) { limiter.limit }
|
4
|
+
let(:error) { Redis::TimeoutError }
|
5
|
+
|
6
|
+
describe '.call' do
|
7
|
+
let(:instance) { described_class.new(errors: errors, on_fail: on_fail) }
|
8
|
+
let(:errors) { nil }
|
9
|
+
let(:on_fail) { nil }
|
10
|
+
|
11
|
+
it 'returns the blocks value' do
|
12
|
+
expect(instance.call { lock }).to be lock
|
13
|
+
end
|
14
|
+
|
15
|
+
context 'when there is an error during lock acquisition' do
|
16
|
+
subject { instance.call { raise error } }
|
17
|
+
|
18
|
+
it 'still returns a lock' do
|
19
|
+
expect(subject).to be_a Berater::Lock
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'creates a new, fake lock' do
|
23
|
+
expect(Berater::Lock).to receive(:new)
|
24
|
+
subject
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'returns a lock that is releasable' do
|
28
|
+
expect(subject.release).to be true
|
29
|
+
end
|
30
|
+
|
31
|
+
context 'when an on_fail handler is defined' do
|
32
|
+
let(:on_fail) { double(Proc) }
|
33
|
+
|
34
|
+
it 'calls the handler' do
|
35
|
+
expect(on_fail).to receive(:call).with(error)
|
36
|
+
subject
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'when the error is an IOError' do
|
41
|
+
let(:error) { IOError }
|
42
|
+
|
43
|
+
it 'would normally not catch the error' do
|
44
|
+
expect { subject }.to raise_error(error)
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'and errors option is set' do
|
48
|
+
let(:errors) { [ error ] }
|
49
|
+
|
50
|
+
it 'catches the error' do
|
51
|
+
expect { subject }.not_to raise_error
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'when there is an error during lock release' do
|
58
|
+
subject { instance.call { lock }.release }
|
59
|
+
|
60
|
+
before do
|
61
|
+
expect(lock).to receive(:release).and_raise(error)
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'handles the exception' do
|
65
|
+
expect { subject }.not_to raise_error
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'returns false since lock was not released' do
|
69
|
+
is_expected.to be false
|
70
|
+
end
|
71
|
+
|
72
|
+
context 'when an on_fail handler is defined' do
|
73
|
+
let(:on_fail) { double(Proc) }
|
74
|
+
|
75
|
+
it 'calls the handler' do
|
76
|
+
expect(on_fail).to receive(:call).with(Exception)
|
77
|
+
subject
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'when the error is an IOError' do
|
82
|
+
let(:error) { IOError }
|
83
|
+
|
84
|
+
it 'would normally not catch the error' do
|
85
|
+
expect { subject }.to raise_error(error)
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'and errors option is set' do
|
89
|
+
let(:errors) { [ error ] }
|
90
|
+
|
91
|
+
it 'catches the error' do
|
92
|
+
expect { subject }.not_to raise_error
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'when there is no lock' do
|
99
|
+
it 'does not crash' do
|
100
|
+
instance.call {}
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
context 'when the lock is not a lock' do
|
105
|
+
it 'does not crash' do
|
106
|
+
instance.call { :foo }
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
context 'when there is an error during lock acquisition' do
|
112
|
+
before do
|
113
|
+
expect(limiter).to receive(:acquire_lock).and_raise(error)
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'raises an exception for the caller' do
|
117
|
+
expect { limiter.limit }.to raise_error(error)
|
118
|
+
end
|
119
|
+
|
120
|
+
context 'when FailOpen middleware is enabled' do
|
121
|
+
before do
|
122
|
+
Berater.middleware.use described_class
|
123
|
+
end
|
124
|
+
|
125
|
+
it 'fails open' do
|
126
|
+
expect(limiter.limit).to be_a Berater::Lock
|
127
|
+
end
|
128
|
+
|
129
|
+
it 'returns the intended result' do
|
130
|
+
expect(limiter.limit { 123 }).to be 123
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
context 'when FailOpen middleware is enabled with callback' do
|
135
|
+
before do
|
136
|
+
Berater.middleware.use described_class, on_fail: on_fail
|
137
|
+
end
|
138
|
+
let(:on_fail) { double(Proc) }
|
139
|
+
|
140
|
+
it 'calls the callback' do
|
141
|
+
expect(on_fail).to receive(:call).with(Exception)
|
142
|
+
limiter.limit
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
context 'when there is an error during lock release' do
|
148
|
+
before do
|
149
|
+
allow(limiter).to receive(:acquire_lock).and_return(lock)
|
150
|
+
allow(lock).to receive(:release).and_raise(error)
|
151
|
+
end
|
152
|
+
|
153
|
+
it 'acquires a lock' do
|
154
|
+
expect(limiter.limit).to be_a Berater::Lock
|
155
|
+
expect(limiter.limit).to be lock
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'raises an exception when lock is released' do
|
159
|
+
expect {
|
160
|
+
limiter.limit.release
|
161
|
+
}.to raise_error(error)
|
162
|
+
end
|
163
|
+
|
164
|
+
it 'raises an exception when lock is auto released' do
|
165
|
+
expect {
|
166
|
+
limiter.limit {}
|
167
|
+
}.to raise_error(error)
|
168
|
+
end
|
169
|
+
|
170
|
+
context 'when FailOpen middleware is enabled' do
|
171
|
+
before do
|
172
|
+
Berater.middleware.use described_class
|
173
|
+
end
|
174
|
+
|
175
|
+
it 'fails open' do
|
176
|
+
expect { limiter.limit.release }.not_to raise_error
|
177
|
+
end
|
178
|
+
|
179
|
+
it 'returns the intended result' do
|
180
|
+
expect(limiter.limit { 123 }).to be 123
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
context 'when FailOpen middleware is enabled with callback' do
|
185
|
+
before do
|
186
|
+
Berater.middleware.use described_class, on_fail: on_fail
|
187
|
+
end
|
188
|
+
let(:on_fail) { double(Proc) }
|
189
|
+
|
190
|
+
it 'calls the callback' do
|
191
|
+
expect(on_fail).to receive(:call).with(Exception)
|
192
|
+
limiter.limit {}
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
describe Berater::Middleware::LoadShedder do
|
2
|
+
describe '#call' do
|
3
|
+
subject { described_class.new }
|
4
|
+
|
5
|
+
it 'yields' do
|
6
|
+
expect {|b| subject.call(&b) }.to yield_control
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'passes through capacity and cost options' do
|
10
|
+
opts = {
|
11
|
+
capacity: 1,
|
12
|
+
cost: 2,
|
13
|
+
}
|
14
|
+
|
15
|
+
subject.call(**opts) do |**passed_opts|
|
16
|
+
expect(passed_opts).to eq(opts)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'strips out priority from options' do
|
21
|
+
opts = {
|
22
|
+
capacity: 1,
|
23
|
+
priority: 3,
|
24
|
+
}
|
25
|
+
|
26
|
+
subject.call(**opts) do |**passed_opts|
|
27
|
+
expect(passed_opts.keys).not_to include(:priority)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'keeps full capacity for priority 1' do
|
32
|
+
subject.call(capacity: 100, priority: 1) do |capacity:|
|
33
|
+
expect(capacity).to eq 100
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'adjusts the capactiy according to priority' do
|
38
|
+
subject.call(capacity: 100, priority: 2) do |capacity:|
|
39
|
+
expect(capacity).to be < 100
|
40
|
+
end
|
41
|
+
|
42
|
+
subject.call(capacity: 100, priority: 5) do |capacity:|
|
43
|
+
expect(capacity).to eq 60
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'works with a fractional priority' do
|
48
|
+
subject.call(capacity: 100, priority: 1.5) do |capacity:|
|
49
|
+
expect(capacity).to be < 100
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context 'with a default priority' do
|
54
|
+
subject { described_class.new(default_priority: 5) }
|
55
|
+
|
56
|
+
it 'keeps full capacity for priority 1' do
|
57
|
+
subject.call(capacity: 100, priority: 1) do |capacity:|
|
58
|
+
expect(capacity).to eq 100
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'uses the default priority' do
|
63
|
+
subject.call(capacity: 100) do |capacity:|
|
64
|
+
expect(capacity).to eq 60
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'with a stringified priority' do
|
70
|
+
it 'casts the value' do
|
71
|
+
subject.call(capacity: 100, priority: '5') do |capacity:|
|
72
|
+
expect(capacity).to eq 60
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context 'with a bogus priority value' do
|
78
|
+
it 'ignores the priority option' do
|
79
|
+
subject.call(capacity: 100, priority: nil) do |capacity:|
|
80
|
+
expect(capacity).to eq 100
|
81
|
+
end
|
82
|
+
|
83
|
+
subject.call(capacity: 100, priority: -1) do |capacity:|
|
84
|
+
expect(capacity).to eq 100
|
85
|
+
end
|
86
|
+
|
87
|
+
subject.call(capacity: 100, priority: 0) do |capacity:|
|
88
|
+
expect(capacity).to eq 100
|
89
|
+
end
|
90
|
+
|
91
|
+
subject.call(capacity: 100, priority: 50) do |capacity:|
|
92
|
+
expect(capacity).to eq 100
|
93
|
+
end
|
94
|
+
|
95
|
+
subject.call(capacity: 100, priority: 'abc') do |capacity:|
|
96
|
+
expect(capacity).to eq 100
|
97
|
+
end
|
98
|
+
|
99
|
+
subject.call(capacity: 100, priority: :abc) do |capacity:|
|
100
|
+
expect(capacity).to eq 100
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
context 'with a limiter' do
|
107
|
+
before do
|
108
|
+
Berater.middleware.use Berater::Middleware::LoadShedder
|
109
|
+
end
|
110
|
+
|
111
|
+
shared_examples 'limiter load shedding' do |limiter|
|
112
|
+
it 'passes through the capactiy properly' do
|
113
|
+
expect(limiter).to receive(:inner_limit).with(
|
114
|
+
hash_including(capacity: 100)
|
115
|
+
).and_call_original
|
116
|
+
|
117
|
+
limiter.limit
|
118
|
+
end
|
119
|
+
|
120
|
+
it 'scales the capactiy with priority' do
|
121
|
+
expect(limiter).to receive(:inner_limit).with(
|
122
|
+
hash_including(capacity: 60)
|
123
|
+
).and_call_original
|
124
|
+
|
125
|
+
limiter.limit(priority: 5)
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'overloads properly' do
|
129
|
+
60.times { limiter.limit(priority: 5) }
|
130
|
+
|
131
|
+
expect {
|
132
|
+
limiter.limit(priority: 5)
|
133
|
+
}.to be_overloaded
|
134
|
+
|
135
|
+
expect {
|
136
|
+
limiter.limit(priority: 4)
|
137
|
+
}.not_to be_overloaded
|
138
|
+
|
139
|
+
39.times { limiter.limit(priority: 1) }
|
140
|
+
|
141
|
+
expect {
|
142
|
+
limiter.limit(priority: 1)
|
143
|
+
}.to be_overloaded
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
include_examples 'limiter load shedding', Berater::ConcurrencyLimiter.new(:key, 100)
|
148
|
+
include_examples 'limiter load shedding', Berater::RateLimiter.new(:key, 100, :second)
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
require 'datadog/statsd'
|
2
|
+
|
3
|
+
describe Berater::Middleware::Statsd do
|
4
|
+
let(:client) { double(Datadog::Statsd) }
|
5
|
+
|
6
|
+
before do
|
7
|
+
allow(client).to receive(:gauge)
|
8
|
+
allow(client).to receive(:increment)
|
9
|
+
allow(client).to receive(:timing)
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '#call' do
|
13
|
+
subject { described_class.new(client, **client_opts).call(limiter, **opts, &block) }
|
14
|
+
|
15
|
+
let(:client_opts) { {} }
|
16
|
+
let(:limiter) { double(Berater::Limiter, key: :key, capacity: 5) }
|
17
|
+
let(:lock) { double(Berater::Lock, capacity: 4, contention: 2) }
|
18
|
+
let(:opts) { { capacity: lock.capacity, cost: 1 } }
|
19
|
+
let(:block) { lambda { lock } }
|
20
|
+
|
21
|
+
after { subject }
|
22
|
+
|
23
|
+
it 'returns a lock' do
|
24
|
+
expect(subject).to be lock
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'tracks the call' do
|
28
|
+
expect(client).to receive(:timing).with(
|
29
|
+
'berater.limiter.limit',
|
30
|
+
Float,
|
31
|
+
tags: {
|
32
|
+
key: limiter.key,
|
33
|
+
limiter: String,
|
34
|
+
overloaded: false,
|
35
|
+
},
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'tracks limiter capacity' do
|
40
|
+
expect(client).to receive(:gauge).with(
|
41
|
+
'berater.limiter.capacity',
|
42
|
+
limiter.capacity,
|
43
|
+
Hash
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'when custom tags are passed in' do
|
48
|
+
let(:opts) { { tags: { abc: 123 } } }
|
49
|
+
|
50
|
+
it 'incorporates the tags' do
|
51
|
+
expect(client).to receive(:timing) do |*, tags:|
|
52
|
+
expect(tags).to include(opts[:tags])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'with global tags' do
|
58
|
+
let(:client_opts) { { tags: { abc: 123 } }}
|
59
|
+
|
60
|
+
it 'incorporates the tags' do
|
61
|
+
expect(client).to receive(:timing) do |*, tags:|
|
62
|
+
expect(tags).to include(client_opts[:tags])
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
context 'with global tag callback' do
|
68
|
+
let(:client_opts) { { tags: callback }}
|
69
|
+
let(:callback) { double(Proc) }
|
70
|
+
|
71
|
+
it 'calls the callback' do
|
72
|
+
expect(callback).to receive(:call).with(limiter, **opts)
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'incorporates the tags' do
|
76
|
+
expect(callback).to receive(:call).and_return({ abc: 123 })
|
77
|
+
|
78
|
+
expect(client).to receive(:timing) do |*, tags:|
|
79
|
+
expect(tags).to include(abc: 123)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
context 'with a limiter' do
|
86
|
+
before do
|
87
|
+
Berater.middleware.use described_class, client
|
88
|
+
end
|
89
|
+
|
90
|
+
let(:limiter) { Berater::ConcurrencyLimiter.new(:key, 3) }
|
91
|
+
|
92
|
+
it 'tracks calls to limit' do
|
93
|
+
expect(client).to receive(:timing) do |*, tags:|
|
94
|
+
expect(tags[:limiter]).to eq 'ConcurrencyLimiter'
|
95
|
+
end
|
96
|
+
|
97
|
+
expect(client).to receive(:gauge).with(
|
98
|
+
'berater.limiter.capacity',
|
99
|
+
limiter.capacity,
|
100
|
+
Hash,
|
101
|
+
)
|
102
|
+
|
103
|
+
expect(client).to receive(:gauge).with(
|
104
|
+
'berater.limiter.contention',
|
105
|
+
1,
|
106
|
+
Hash,
|
107
|
+
)
|
108
|
+
|
109
|
+
limiter.limit
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'tracks each call' do
|
113
|
+
expect(client).to receive(:gauge).with(
|
114
|
+
'berater.limiter.contention',
|
115
|
+
1,
|
116
|
+
Hash,
|
117
|
+
)
|
118
|
+
|
119
|
+
expect(client).to receive(:gauge).with(
|
120
|
+
'berater.limiter.contention',
|
121
|
+
2,
|
122
|
+
Hash,
|
123
|
+
)
|
124
|
+
|
125
|
+
2.times { limiter.limit }
|
126
|
+
end
|
127
|
+
|
128
|
+
context 'when an exception is raised' do
|
129
|
+
before do
|
130
|
+
expect(limiter).to receive(:redis).and_raise(error)
|
131
|
+
end
|
132
|
+
|
133
|
+
let(:error) { Redis::TimeoutError }
|
134
|
+
|
135
|
+
it 'tracks limiter exceptions' do
|
136
|
+
expect(client).to receive(:increment).with(
|
137
|
+
'berater.limiter.error',
|
138
|
+
tags: hash_including(type: error.to_s),
|
139
|
+
)
|
140
|
+
|
141
|
+
expect { limiter.limit }.to raise_error(error)
|
142
|
+
end
|
143
|
+
|
144
|
+
context 'with FailOpen middleware inserted after' do
|
145
|
+
before do
|
146
|
+
Berater.middleware.use Berater::Middleware::FailOpen
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'does not track the exception' do
|
150
|
+
expect(client).not_to receive(:increment).with(
|
151
|
+
'berater.limiter.error',
|
152
|
+
)
|
153
|
+
|
154
|
+
limiter.limit
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'does not track lock-based stats' do
|
158
|
+
expect(client).not_to receive(:gauge).with(
|
159
|
+
'berater.lock.capacity',
|
160
|
+
)
|
161
|
+
|
162
|
+
expect(client).not_to receive(:gauge).with(
|
163
|
+
'berater.limiter.contention',
|
164
|
+
)
|
165
|
+
|
166
|
+
limiter.limit
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
context 'with FailOpen middleware inserted before' do
|
171
|
+
before do
|
172
|
+
Berater.middleware.prepend Berater::Middleware::FailOpen
|
173
|
+
end
|
174
|
+
|
175
|
+
it 'tracks the exception' do
|
176
|
+
expect(client).to receive(:increment).with(
|
177
|
+
'berater.limiter.error',
|
178
|
+
Hash,
|
179
|
+
)
|
180
|
+
|
181
|
+
limiter.limit
|
182
|
+
end
|
183
|
+
|
184
|
+
it 'does not track lock-based stats' do
|
185
|
+
expect(client).not_to receive(:gauge).with(
|
186
|
+
'berater.lock.capacity',
|
187
|
+
)
|
188
|
+
|
189
|
+
expect(client).not_to receive(:gauge).with(
|
190
|
+
'berater.limiter.contention',
|
191
|
+
)
|
192
|
+
|
193
|
+
limiter.limit
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
context 'when the limiter is overloaded' do
|
199
|
+
before { limiter.capacity.times { limiter.limit } }
|
200
|
+
|
201
|
+
after do
|
202
|
+
expect { limiter.limit }.to be_overloaded
|
203
|
+
end
|
204
|
+
|
205
|
+
it 'tracks the limit call' do
|
206
|
+
expect(client).to receive(:timing).with(
|
207
|
+
'berater.limiter.limit',
|
208
|
+
Float,
|
209
|
+
tags: hash_including(overloaded: true),
|
210
|
+
)
|
211
|
+
end
|
212
|
+
|
213
|
+
it 'tracks contention' do
|
214
|
+
expect(client).not_to receive(:gauge).with(
|
215
|
+
'berater.limiter.contention',
|
216
|
+
limiter.capacity,
|
217
|
+
)
|
218
|
+
end
|
219
|
+
|
220
|
+
it 'does not track the exception' do
|
221
|
+
expect(client).not_to receive(:increment).with(
|
222
|
+
'berater.limiter.error',
|
223
|
+
)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
data/spec/middleware_spec.rb
CHANGED
@@ -105,6 +105,16 @@ describe 'Berater.middleware' do
|
|
105
105
|
expect(middleware).to receive(:call)
|
106
106
|
expect(limiter.limit).to be nil
|
107
107
|
end
|
108
|
+
|
109
|
+
it 'can intercept the lock' do
|
110
|
+
expect(middleware).to receive(:call) do |&block|
|
111
|
+
lock = block.call
|
112
|
+
expect(lock).to be_a Berater::Lock
|
113
|
+
expect(lock.capacity).to eq limiter.capacity
|
114
|
+
end
|
115
|
+
|
116
|
+
limiter.limit
|
117
|
+
end
|
108
118
|
end
|
109
119
|
end
|
110
120
|
end
|
data/spec/rate_limiter_spec.rb
CHANGED
@@ -19,12 +19,13 @@ describe Berater::RateLimiter do
|
|
19
19
|
describe '#capacity' do
|
20
20
|
def expect_capacity(capacity)
|
21
21
|
limiter = described_class.new(:key, capacity, :second)
|
22
|
-
expect(limiter.capacity).to eq capacity
|
22
|
+
expect(limiter.capacity).to eq capacity.to_f
|
23
23
|
end
|
24
24
|
|
25
25
|
it { expect_capacity(0) }
|
26
26
|
it { expect_capacity(1) }
|
27
27
|
it { expect_capacity(1.5) }
|
28
|
+
it { expect_capacity('1.5') }
|
28
29
|
it { expect_capacity(100) }
|
29
30
|
|
30
31
|
context 'with erroneous values' do
|
@@ -35,7 +36,7 @@ describe Berater::RateLimiter do
|
|
35
36
|
end
|
36
37
|
|
37
38
|
it { expect_bad_capacity(-1) }
|
38
|
-
it { expect_bad_capacity('
|
39
|
+
it { expect_bad_capacity('abc') }
|
39
40
|
it { expect_bad_capacity(:one) }
|
40
41
|
it { expect_bad_capacity(Float::INFINITY) }
|
41
42
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: berater
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Daniel Pepper
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-11-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: meddleware
|
@@ -80,6 +80,20 @@ dependencies:
|
|
80
80
|
- - ">="
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: dogstatsd-ruby
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '4.3'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '4.3'
|
83
97
|
- !ruby/object:Gem::Dependency
|
84
98
|
name: rake
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -150,6 +164,10 @@ files:
|
|
150
164
|
- lib/berater/limiter_set.rb
|
151
165
|
- lib/berater/lock.rb
|
152
166
|
- lib/berater/lua_script.rb
|
167
|
+
- lib/berater/middleware.rb
|
168
|
+
- lib/berater/middleware/fail_open.rb
|
169
|
+
- lib/berater/middleware/load_shedder.rb
|
170
|
+
- lib/berater/middleware/statsd.rb
|
153
171
|
- lib/berater/rate_limiter.rb
|
154
172
|
- lib/berater/rspec.rb
|
155
173
|
- lib/berater/rspec/matchers.rb
|
@@ -167,6 +185,9 @@ files:
|
|
167
185
|
- spec/limiter_spec.rb
|
168
186
|
- spec/lua_script_spec.rb
|
169
187
|
- spec/matchers_spec.rb
|
188
|
+
- spec/middleware/fail_open_spec.rb
|
189
|
+
- spec/middleware/load_shedder_spec.rb
|
190
|
+
- spec/middleware/statsd_spec.rb
|
170
191
|
- spec/middleware_spec.rb
|
171
192
|
- spec/rate_limiter_spec.rb
|
172
193
|
- spec/riddle_spec.rb
|
@@ -193,24 +214,27 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
193
214
|
- !ruby/object:Gem::Version
|
194
215
|
version: '0'
|
195
216
|
requirements: []
|
196
|
-
rubygems_version: 3.
|
217
|
+
rubygems_version: 3.1.6
|
197
218
|
signing_key:
|
198
219
|
specification_version: 4
|
199
220
|
summary: Berater
|
200
221
|
test_files:
|
201
|
-
- spec/
|
202
|
-
- spec/
|
222
|
+
- spec/rate_limiter_spec.rb
|
223
|
+
- spec/middleware/load_shedder_spec.rb
|
224
|
+
- spec/middleware/statsd_spec.rb
|
225
|
+
- spec/middleware/fail_open_spec.rb
|
226
|
+
- spec/matchers_spec.rb
|
203
227
|
- spec/dsl_refinement_spec.rb
|
228
|
+
- spec/test_mode_spec.rb
|
229
|
+
- spec/middleware_spec.rb
|
204
230
|
- spec/dsl_spec.rb
|
205
|
-
- spec/inhibitor_spec.rb
|
206
|
-
- spec/limiter_set_spec.rb
|
207
|
-
- spec/limiter_spec.rb
|
208
231
|
- spec/lua_script_spec.rb
|
209
|
-
- spec/
|
210
|
-
- spec/middleware_spec.rb
|
211
|
-
- spec/rate_limiter_spec.rb
|
232
|
+
- spec/concurrency_limiter_spec.rb
|
212
233
|
- spec/riddle_spec.rb
|
234
|
+
- spec/limiter_set_spec.rb
|
235
|
+
- spec/utils_spec.rb
|
236
|
+
- spec/berater_spec.rb
|
237
|
+
- spec/limiter_spec.rb
|
213
238
|
- spec/static_limiter_spec.rb
|
214
|
-
- spec/
|
239
|
+
- spec/inhibitor_spec.rb
|
215
240
|
- spec/unlimiter_spec.rb
|
216
|
-
- spec/utils_spec.rb
|