dhc 2.0.1 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/README.md +25 -0
- data/lib/dhc/interceptors/throttle.rb +81 -44
- data/lib/dhc/rspec.rb +1 -1
- data/lib/dhc/version.rb +1 -1
- data/spec/interceptors/throttle/main_spec.rb +4 -4
- data/spec/interceptors/throttle/manually_spec.rb +82 -0
- data/spec/interceptors/throttle/reset_track_spec.rb +0 -1
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8e8b5fcc2c830eb7bb23451c7b7063acafe94bad5cc036e9e13a306b883aea82
|
4
|
+
data.tar.gz: b1095ae1fc29cdb675bddf4c9b85c3456d846a1cfe63200f6bd05b6b3396644c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 006f5af5737d3b51c724188ea676491ed13bbae2e21a252dec0f81a1f0ffa03508ce3b268539d87ea2f95465cbb9e5e634193d7669cffd59c0f2a1ae471f32ed
|
7
|
+
data.tar.gz: 124f5fd5cc80589323d065478f4d3d926e40c63e4e793ea20c4ade116032d095feb7b6218e76a8d617f2001a8e94674134b7c0500dd6504c3a711ea03e781c31
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
@@ -854,6 +854,8 @@ If it raises, it forwards the request and response object to rollbar, which cont
|
|
854
854
|
|
855
855
|
The throttle interceptor allows you to raise an exception if a predefined quota of a provider request limit is reached in advance.
|
856
856
|
|
857
|
+
The throttle state (tracker) is stored in Rails.cache.
|
858
|
+
|
857
859
|
```ruby
|
858
860
|
DHC.configure do |c|
|
859
861
|
c.interceptors = [DHC::Throttle]
|
@@ -889,10 +891,33 @@ DHC.get('http://depay.fi', options)
|
|
889
891
|
* `remaining`:
|
890
892
|
* a hash pointing at the response header containing the current amount of remaining requests
|
891
893
|
* a proc that receives the response as argument and returns the current amount of remaining requests
|
894
|
+
* not set: will track remaining by counting down limit until `expires`
|
892
895
|
* `expires`:
|
893
896
|
* a hash pointing at the response header containing the timestamp when the quota will reset
|
894
897
|
* a proc that receives the response as argument and returns the timestamp when the quota will reset
|
898
|
+
* an ActiveSupport::Duration e.g. 1.minute
|
899
|
+
|
900
|
+
Example for throttling manually without relating to any information in the response:
|
895
901
|
|
902
|
+
```ruby
|
903
|
+
options = {
|
904
|
+
throttle: {
|
905
|
+
track: true,
|
906
|
+
break: '80%',
|
907
|
+
provider: 'depay.fi',
|
908
|
+
limit: 100,
|
909
|
+
expires: 1.minute
|
910
|
+
}
|
911
|
+
}
|
912
|
+
|
913
|
+
DHC.get('http://depay.fi', options)
|
914
|
+
```
|
915
|
+
|
916
|
+
Will reset every minute, and will allow up to 80 requests per minute. The 81st request attempt within a minute will raise:
|
917
|
+
|
918
|
+
```ruby
|
919
|
+
DHC::Throttle::OutOfQuota: Reached predefined quota for depay.fi
|
920
|
+
```
|
896
921
|
|
897
922
|
#### Zipkin
|
898
923
|
|
@@ -6,80 +6,117 @@ class DHC::Throttle < DHC::Interceptor
|
|
6
6
|
class OutOfQuota < StandardError
|
7
7
|
end
|
8
8
|
|
9
|
+
CACHE_KEY = 'DHC/throttle/tracker/v1'
|
10
|
+
|
9
11
|
class << self
|
10
|
-
|
12
|
+
|
13
|
+
def tracker(provider)
|
14
|
+
(Rails.cache.read(CACHE_KEY) || {})[provider] || {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def tracker=(track)
|
18
|
+
Rails.cache.write(CACHE_KEY, tracker(track[:provider]).merge({ track[:provider] => track }))
|
19
|
+
end
|
11
20
|
end
|
12
21
|
|
13
22
|
def before_request
|
14
|
-
options = request.options.dig(:throttle)
|
15
23
|
return unless options
|
16
|
-
|
17
|
-
return unless break_options
|
18
|
-
break_when_quota_reached! if break_options.match('%')
|
24
|
+
break! if break?
|
19
25
|
end
|
20
26
|
|
21
27
|
def after_response
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
expires: expires(options: options[:expires], response: response)
|
28
|
+
return unless track?
|
29
|
+
self.class.tracker = {
|
30
|
+
provider: options.dig(:provider),
|
31
|
+
limit: limit,
|
32
|
+
remaining: remaining,
|
33
|
+
expires: expires
|
29
34
|
}
|
30
35
|
end
|
31
36
|
|
32
37
|
private
|
33
38
|
|
34
|
-
def
|
35
|
-
|
39
|
+
def options
|
40
|
+
@options ||= request.options.dig(:throttle) || {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def provider
|
44
|
+
@provider ||= request.options.dig(:throttle, :provider)
|
45
|
+
end
|
46
|
+
|
47
|
+
def track?
|
48
|
+
(options.dig(:remaining) && [options.dig(:track), response.headers].none?(&:blank?) ||
|
49
|
+
options.dig(:track).present?
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def break?
|
54
|
+
@do_break ||= begin
|
55
|
+
return if options.dig(:break) && !options.dig(:break).match('%')
|
56
|
+
tracker = self.class.tracker(options[:provider])
|
57
|
+
return if tracker.blank? || tracker[:remaining].blank? || tracker[:limit].blank? || tracker[:expires].blank?
|
58
|
+
return if Time.zone.now > tracker[:expires]
|
59
|
+
remaining = tracker[:remaining] * 100
|
60
|
+
limit = tracker[:limit]
|
61
|
+
remaining_quota = 100 - options[:break].to_i
|
62
|
+
remaining < remaining_quota * limit
|
63
|
+
end
|
36
64
|
end
|
37
65
|
|
38
|
-
def
|
39
|
-
|
40
|
-
track = (self.class.track || {}).dig(options[:provider])
|
41
|
-
return if track.blank? || track[:remaining].blank? || track[:limit].blank? || track[:expires].blank?
|
42
|
-
return if Time.zone.now > track[:expires]
|
43
|
-
# avoid floats by multiplying with 100
|
44
|
-
remaining = track[:remaining] * 100
|
45
|
-
limit = track[:limit]
|
46
|
-
quota = 100 - options[:break].to_i
|
47
|
-
raise(OutOfQuota, "Reached predefined quota for #{options[:provider]}") if remaining < quota * limit
|
66
|
+
def break!
|
67
|
+
raise(OutOfQuota, "Reached predefined quota for #{provider}")
|
48
68
|
end
|
49
69
|
|
50
|
-
def limit
|
70
|
+
def limit
|
51
71
|
@limit ||=
|
52
|
-
if options.is_a?(Proc)
|
53
|
-
options.call(response)
|
54
|
-
elsif options.is_a?(Integer)
|
55
|
-
options
|
56
|
-
elsif options.is_a?(Hash) && options
|
57
|
-
response.headers[options
|
72
|
+
if options.dig(:limit).is_a?(Proc)
|
73
|
+
options.dig(:limit).call(response)
|
74
|
+
elsif options.dig(:limit).is_a?(Integer)
|
75
|
+
options.dig(:limit)
|
76
|
+
elsif options.dig(:limit).is_a?(Hash) && options.dig(:limit, :header) && response.headers
|
77
|
+
response.headers[options.dig(:limit, :header)]&.to_i
|
58
78
|
end
|
59
79
|
end
|
60
80
|
|
61
|
-
def remaining
|
62
|
-
@remaining ||=
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
81
|
+
def remaining
|
82
|
+
@remaining ||= begin
|
83
|
+
if options.dig(:remaining).is_a?(Proc)
|
84
|
+
options.dig(:remaining).call(response)
|
85
|
+
elsif options.dig(:remaining).is_a?(Hash) && options.dig(:remaining, :header) && response.headers
|
86
|
+
response.headers[options.dig(:remaining, :header)]&.to_i
|
87
|
+
elsif options.dig(:remaining).blank?
|
88
|
+
remaining_before = self.class.tracker(provider).dig(:remaining) || request.options.dig(:throttle, :limit)
|
89
|
+
expires = self.class.tracker(provider).dig(:expires)
|
90
|
+
if expires && expires > DateTime.now
|
91
|
+
remaining_before - 1
|
92
|
+
else
|
93
|
+
request.options.dig(:throttle, :limit) - 1
|
68
94
|
end
|
69
95
|
end
|
96
|
+
end
|
70
97
|
end
|
71
98
|
|
72
|
-
def expires
|
73
|
-
@expires ||=
|
74
|
-
|
99
|
+
def expires
|
100
|
+
@expires ||= begin
|
101
|
+
if options.dig(:expires).is_a?(ActiveSupport::Duration) && self.class.tracker(provider).dig(:expires).present?
|
102
|
+
if self.class.tracker(provider)[:expires] > DateTime.now
|
103
|
+
self.class.tracker(provider)[:expires]
|
104
|
+
else
|
105
|
+
DateTime.now + options.dig(:expires)
|
106
|
+
end
|
107
|
+
elsif options.dig(:expires).is_a?(Hash) && options.dig(:expires, :header)
|
75
108
|
|
76
|
-
|
77
|
-
|
109
|
+
convert_expire_value(response.headers[options.dig(:expires, :header)]) if response.headers
|
110
|
+
else
|
111
|
+
convert_expire_value(options.dig(:expires))
|
112
|
+
end
|
113
|
+
end
|
78
114
|
end
|
79
115
|
|
80
|
-
def
|
116
|
+
def convert_expire_value(value)
|
81
117
|
return if value.blank?
|
82
118
|
return value.call(response) if value.is_a?(Proc)
|
119
|
+
return DateTime.now + value if value.is_a?(ActiveSupport::Duration)
|
83
120
|
return Time.parse(value) if value.match(/GMT/)
|
84
121
|
Time.zone.at(value.to_i).to_datetime
|
85
122
|
end
|
data/lib/dhc/rspec.rb
CHANGED
data/lib/dhc/version.rb
CHANGED
@@ -25,8 +25,8 @@ describe DHC::Throttle do
|
|
25
25
|
end
|
26
26
|
|
27
27
|
before(:each) do
|
28
|
-
DHC::Throttle.track = nil
|
29
28
|
DHC.config.interceptors = [DHC::Throttle]
|
29
|
+
Rails.cache.write(DHC::Throttle::CACHE_KEY, nil)
|
30
30
|
|
31
31
|
stub_request(:get, 'http://depay.fi').to_return(
|
32
32
|
headers: { 'limit' => quota_limit, 'remaining' => quota_remaining, 'reset' => quota_reset }
|
@@ -35,8 +35,8 @@ describe DHC::Throttle do
|
|
35
35
|
|
36
36
|
it 'tracks the request limits based on response data' do
|
37
37
|
DHC.get('http://depay.fi', options)
|
38
|
-
expect(DHC
|
39
|
-
expect(DHC
|
38
|
+
expect(Rails.cache.read('DHC/throttle/tracker/v1')[provider][:limit]).to eq quota_limit
|
39
|
+
expect(Rails.cache.read('DHC/throttle/tracker/v1')[provider][:remaining]).to eq quota_remaining
|
40
40
|
end
|
41
41
|
|
42
42
|
context 'fix predefined integer for limit' do
|
@@ -44,7 +44,7 @@ describe DHC::Throttle do
|
|
44
44
|
|
45
45
|
it 'tracks the limit based on initialy provided data' do
|
46
46
|
DHC.get('http://depay.fi', options)
|
47
|
-
expect(DHC
|
47
|
+
expect(Rails.cache.read('DHC/throttle/tracker/v1')[provider][:limit]).to eq options_limit
|
48
48
|
end
|
49
49
|
end
|
50
50
|
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails_helper'
|
4
|
+
|
5
|
+
describe DHC::Throttle do
|
6
|
+
let(:options) do
|
7
|
+
{
|
8
|
+
throttle: {
|
9
|
+
provider: provider,
|
10
|
+
track: true,
|
11
|
+
limit: quota_limit,
|
12
|
+
expires: 1.minute,
|
13
|
+
break: break_after
|
14
|
+
}
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:provider) { 'depay.fi' }
|
19
|
+
let(:quota_limit) { 100 }
|
20
|
+
let(:break_after) { '80%' }
|
21
|
+
|
22
|
+
before(:each) do
|
23
|
+
DHC.config.interceptors = [DHC::Throttle]
|
24
|
+
Rails.cache.write(DHC::Throttle::CACHE_KEY, nil)
|
25
|
+
|
26
|
+
stub_request(:get, 'http://depay.fi').to_return(status: 200)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'tracks the request limits based on response data' do
|
30
|
+
DHC.get('http://depay.fi', options)
|
31
|
+
expect(Rails.cache.read('DHC/throttle/tracker/v1')[provider][:limit]).to eq 100
|
32
|
+
expect(Rails.cache.read('DHC/throttle/tracker/v1')[provider][:remaining]).to eq quota_limit - 1
|
33
|
+
end
|
34
|
+
|
35
|
+
context 'breaks' do
|
36
|
+
let(:quota_limit) { 10 }
|
37
|
+
let(:break_after) { '79%' }
|
38
|
+
|
39
|
+
it 'hit the breaks if throttling quota is reached' do
|
40
|
+
8.times do
|
41
|
+
DHC.get('http://depay.fi', options)
|
42
|
+
end
|
43
|
+
expect { DHC.get('http://depay.fi', options) }.to raise_error(
|
44
|
+
DHC::Throttle::OutOfQuota,
|
45
|
+
'Reached predefined quota for depay.fi'
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
context 'still within quota' do
|
50
|
+
let(:break_after) { '80%' }
|
51
|
+
|
52
|
+
it 'does not hit the breaks' do
|
53
|
+
9.times do
|
54
|
+
DHC.get('http://depay.fi', options)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context 'expires' do
|
61
|
+
let(:break_after) { '80%' }
|
62
|
+
let(:quota_limit) { 10 }
|
63
|
+
|
64
|
+
it 'attempts another request if the quota expired' do
|
65
|
+
9.times do
|
66
|
+
DHC.get('http://depay.fi', options)
|
67
|
+
end
|
68
|
+
expect { DHC.get('http://depay.fi', options) }.to raise_error(
|
69
|
+
DHC::Throttle::OutOfQuota,
|
70
|
+
'Reached predefined quota for depay.fi'
|
71
|
+
)
|
72
|
+
Timecop.travel(Time.zone.now + 1.minute)
|
73
|
+
9.times do
|
74
|
+
DHC.get('http://depay.fi', options)
|
75
|
+
end
|
76
|
+
expect { DHC.get('http://depay.fi', options) }.to raise_error(
|
77
|
+
DHC::Throttle::OutOfQuota,
|
78
|
+
'Reached predefined quota for depay.fi'
|
79
|
+
)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dhc
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0
|
4
|
+
version: 2.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- https://github.com/DePayFi/dhc/contributors
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-12-
|
11
|
+
date: 2021-12-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -338,6 +338,7 @@ files:
|
|
338
338
|
- spec/interceptors/rollbar/invalid_encoding_spec.rb
|
339
339
|
- spec/interceptors/rollbar/main_spec.rb
|
340
340
|
- spec/interceptors/throttle/main_spec.rb
|
341
|
+
- spec/interceptors/throttle/manually_spec.rb
|
341
342
|
- spec/interceptors/throttle/reset_track_spec.rb
|
342
343
|
- spec/interceptors/zipkin/distributed_tracing_spec.rb
|
343
344
|
- spec/rails_helper.rb
|
@@ -492,6 +493,7 @@ test_files:
|
|
492
493
|
- spec/interceptors/rollbar/invalid_encoding_spec.rb
|
493
494
|
- spec/interceptors/rollbar/main_spec.rb
|
494
495
|
- spec/interceptors/throttle/main_spec.rb
|
496
|
+
- spec/interceptors/throttle/manually_spec.rb
|
495
497
|
- spec/interceptors/throttle/reset_track_spec.rb
|
496
498
|
- spec/interceptors/zipkin/distributed_tracing_spec.rb
|
497
499
|
- spec/rails_helper.rb
|