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 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