berater 0.10.0 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32de3ce22b3804c00224140d747e66fc9de887582f6bd4812c6404cfcba0056e
4
- data.tar.gz: 16054c2814c98bd64fa0ea16ee9ca2110bff6df9cb4a17bf292759d3dcc901e7
3
+ metadata.gz: 72cc5fcf9c68fff6dd3988ff85897b38d6fdcff21c0e6f94d3ec098ca8b35fc7
4
+ data.tar.gz: 49144f0dee2d7197740d39b9da365f9d959c730b7ece1ae761948ec9263ada75
5
5
  SHA512:
6
- metadata.gz: 78b027082f40a7bdc6d64e245359fc5b34f3b2738fdf683bfa28047f788378f46f1068f9c7541071b8e2e043b78bfee0219eaf70239e0ec09274238a979f7085
7
- data.tar.gz: b63f8c4103be7c518c96eed52b98d00d094774acbc9b8df42810c945b210d4e5850a49c38ab318a38a25521da623ffb2940693191753a485779ca03be5232ea4
6
+ metadata.gz: 401a193c358a80098f1250053983d8f1a362cd14649156a036deedeb461fd70202743764d375b04a2b6b4fb08c795ec61d013e1be342626a2c8aad7dab2cb259
7
+ data.tar.gz: 55498f8b1a6bd70a64ed9053b80bc9c95b0d60043f073c302d48bb79b51677d7a3c235c07f35cc51dacec067d0c2cc8dcf972e37fb9e9baae126f69fa8aacb4f
@@ -7,12 +7,12 @@ module Berater
7
7
  options[:redis] || Berater.redis
8
8
  end
9
9
 
10
- def limit(capacity: nil, cost: 1, &block)
11
- capacity ||= @capacity
12
- lock = nil
10
+ def limit(**opts, &block)
11
+ opts[:capacity] ||= @capacity
12
+ opts[:cost] ||= 1
13
13
 
14
- Berater.middleware.call(self, capacity: capacity, cost: cost) do |limiter, **opts|
15
- lock = limiter.inner_limit(**opts)
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
@@ -1,4 +1,5 @@
1
1
  require 'digest'
2
+ require 'redis'
2
3
 
3
4
  module Berater
4
5
  class LuaScript
@@ -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
@@ -0,0 +1,7 @@
1
+ module Berater
2
+ module Middleware
3
+ autoload 'FailOpen', 'berater/middleware/fail_open'
4
+ autoload 'LoadShedder', 'berater/middleware/load_shedder'
5
+ autoload 'Statsd', 'berater/middleware/statsd'
6
+ end
7
+ end
@@ -1,3 +1,3 @@
1
1
  module Berater
2
- VERSION = "0.10.0"
2
+ VERSION = "0.12.0"
3
3
  end
data/lib/berater.rb CHANGED
@@ -2,6 +2,7 @@ require 'berater/limiter'
2
2
  require 'berater/limiter_set'
3
3
  require 'berater/lock'
4
4
  require 'berater/lua_script'
5
+ require 'berater/middleware'
5
6
  require 'berater/utils'
6
7
  require 'berater/version'
7
8
  require 'meddleware'
@@ -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('1') }
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
@@ -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
@@ -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('1') }
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.10.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-09-18 00:00:00.000000000 Z
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.2.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/berater_spec.rb
202
- - spec/concurrency_limiter_spec.rb
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/matchers_spec.rb
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/test_mode_spec.rb
239
+ - spec/inhibitor_spec.rb
215
240
  - spec/unlimiter_spec.rb
216
- - spec/utils_spec.rb