dhc 2.0.1 → 2.1.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/.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
|