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