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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14a53cb09332bb3da43ed44f070d8671b34d5881f4a4b6b44ae4403d90a95ad9
4
- data.tar.gz: 1d1b3a129ad75f53cbe0c1bc03add6805c678f88eb2fc40038c21183742ef69e
3
+ metadata.gz: 8e8b5fcc2c830eb7bb23451c7b7063acafe94bad5cc036e9e13a306b883aea82
4
+ data.tar.gz: b1095ae1fc29cdb675bddf4c9b85c3456d846a1cfe63200f6bd05b6b3396644c
5
5
  SHA512:
6
- metadata.gz: c6beddb11566cab553e511ccc16e626ec9139a14d18ffb3ae60ea3312867e4a207c8f68c2da77494c6b72a487ea845194d4d9ef87fee11ff4aad87850397e955
7
- data.tar.gz: c82721bc5c65649dc0084a739c9ec69dfab35ebb932c06ad62cfc86280dd2e38e0bd8651c74705a06ff1c7e1c9fe64aed17c41ccf747df5a178c8dbe61387915
6
+ metadata.gz: 006f5af5737d3b51c724188ea676491ed13bbae2e21a252dec0f81a1f0ffa03508ce3b268539d87ea2f95465cbb9e5e634193d7669cffd59c0f2a1ae471f32ed
7
+ data.tar.gz: 124f5fd5cc80589323d065478f4d3d926e40c63e4e793ea20c4ade116032d095feb7b6218e76a8d617f2001a8e94674134b7c0500dd6504c3a711ea03e781c31
data/.rubocop.yml CHANGED
@@ -62,6 +62,9 @@ Style/SingleArgumentDig:
62
62
  Metrics/ClassLength:
63
63
  Enabled: false
64
64
 
65
+ Metrics/PerceivedComplexity:
66
+ Enabled: false
67
+
65
68
  Style/OptionalBooleanParameter:
66
69
  Enabled: false
67
70
 
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
- attr_accessor :track
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
- break_options = options.dig(:break)
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
- options = response.request.options.dig(:throttle)
23
- return unless throttle?(options)
24
- self.class.track ||= {}
25
- self.class.track[options.dig(:provider)] = {
26
- limit: limit(options: options[:limit], response: response),
27
- remaining: remaining(options: options[:remaining], response: response),
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 throttle?(options)
35
- [options&.dig(:track), response.headers].none?(&:blank?)
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 break_when_quota_reached!
39
- options = request.options.dig(:throttle)
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(options:, response:)
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[:header]
57
- response.headers[options[:header]]&.to_i
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(options:, response:)
62
- @remaining ||=
63
- begin
64
- if options.is_a?(Proc)
65
- options.call(response)
66
- elsif options.is_a?(Hash) && options[:header]
67
- response.headers[options[:header]]&.to_i
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(options:, response:)
73
- @expires ||= convert_expires(read_expire_option(options, response))
74
- end
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
- def read_expire_option(options, response)
77
- (options.is_a?(Hash) && options[:header]) ? response.headers[options[:header]] : options
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 convert_expires(value)
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
@@ -6,6 +6,6 @@ RSpec.configure do |config|
6
6
  config.before(:each) do
7
7
  DHC::Caching.cache = ActiveSupport::Cache::MemoryStore.new
8
8
  DHC::Caching.cache.clear
9
- DHC::Throttle.track = nil
9
+ Rails.cache.write(DHC::Throttle::CACHE_KEY, nil) if defined? Rails
10
10
  end
11
11
  end
data/lib/dhc/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DHC
4
- VERSION ||= '2.0.1'
4
+ VERSION ||= '2.1.0'
5
5
  end
@@ -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::Throttle.track[provider][:limit]).to eq quota_limit
39
- expect(DHC::Throttle.track[provider][:remaining]).to eq quota_remaining
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::Throttle.track[provider][:limit]).to eq options_limit
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
@@ -23,7 +23,6 @@ describe DHC::Throttle do
23
23
  let(:expires_in) { (Time.zone.now + 1.hour).to_i }
24
24
 
25
25
  before(:each) do
26
- DHC::Throttle.track = nil
27
26
  DHC.config.interceptors = [DHC::Throttle]
28
27
 
29
28
  stub_request(:get, 'http://depay.fi')
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.1
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-14 00:00:00.000000000 Z
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