dhc 1.0.0 → 2.1.1

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: 2b22e30900912a713bfb89ff65a146da3ddba654aa02b9b0352fb857d2df9c33
4
- data.tar.gz: 54958fd32986660a12ecd05fe2e73f74873dea1eaac303a6bee53b5050ebe321
3
+ metadata.gz: a8e0bb9f278c6894075dfd3004f3a639be1d5da7dd02df7b4e6d3f6169ee33ef
4
+ data.tar.gz: f3ee060e925fca3a629f8ca3c53e815c9c33bcb6dcbba75ff57651d1f57595e2
5
5
  SHA512:
6
- metadata.gz: 3153ef2ea5a6602daedfb2d6264d40b3092d047e508c29ec4090e47b9372d820e7199e129acf5fa24468edc38903ed814f8e33acf2ff50f08c30a5c3890facab
7
- data.tar.gz: 0b6753fa4e0792a533ec0667a1084e223f374008a1b999dd6e01e423f18e6e063e099aea321a80681c06ee9885a9d89e009ae737273ebd385e48c4de8a6b3561
6
+ metadata.gz: fbdd2700bd73f043a0d6aa4f37c2505255451be5fd6228df2013951df03f63c3dd964116bcf5a7c05a273a28025d06cab319aca08f19fa7339a7eb349e39c9a9
7
+ data.tar.gz: 99800e3225e33e433218698f075c492be9fa6f59d11c457a09ab3558eabbebb5ecb68b15ed796c2ffea6803cd02199f3d1ffda3d52717a8f0e543e56b55c6982
@@ -8,20 +8,9 @@ jobs:
8
8
 
9
9
  steps:
10
10
  - uses: actions/checkout@v2
11
- - uses: actions/setup-ruby@v1
11
+ - uses: ruby/setup-ruby@master
12
12
  with:
13
- ruby-version: 2.7.2
14
- - name: Cache Ruby Gems
15
- uses: actions/cache@v2
16
- with:
17
- path: /.tmp/vendor/bundle
18
- key: ${{ runner.os }}-gems-latest-${{ hashFiles('**/Gemfile.lock') }}
19
- restore-keys: |
20
- ${{ runner.os }}-gems-latest-
21
- - name: Bundle Install
22
- run: |
23
- bundle config path /.tmp/vendor/bundle
24
- bundle install --jobs 4 --retry 3
13
+ bundler-cache: true
25
14
  - name: Run Rubocop
26
15
  run: |
27
16
  bundle exec rubocop
@@ -8,20 +8,9 @@ jobs:
8
8
 
9
9
  steps:
10
10
  - uses: actions/checkout@v2
11
- - uses: actions/setup-ruby@v1
11
+ - uses: ruby/setup-ruby@master
12
12
  with:
13
- ruby-version: 2.7.2
14
- - name: Cache Ruby Gems
15
- uses: actions/cache@v2
16
- with:
17
- path: /.tmp/vendor/bundle
18
- key: ${{ runner.os }}-gems-latest-${{ hashFiles('**/Gemfile.lock') }}
19
- restore-keys: |
20
- ${{ runner.os }}-gems-latest-
21
- - name: Bundle Install
22
- run: |
23
- bundle config path /.tmp/vendor/bundle
24
- bundle install --jobs 4 --retry 3
13
+ bundler-cache: true
25
14
  - name: Run Tests
26
15
  run: |
27
16
  bundle exec rspec
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
 
@@ -941,7 +966,7 @@ config.middleware.use ZipkinTracer::RackHandler, {
941
966
 
942
967
  #### Interceptor callbacks
943
968
 
944
- `before_raw_request` is called before the raw typhoeus request is prepared/created.
969
+ `before_init` is called before the raw typhoeus request has been initialized.
945
970
 
946
971
  `before_request` is called when the request is prepared and about to be executed.
947
972
 
data/lib/dhc/error.rb CHANGED
@@ -70,10 +70,7 @@ class DHC::Error < StandardError
70
70
 
71
71
  debug = []
72
72
  debug << [request.method, request.url].map { |str| self.class.fix_invalid_encoding(str) }.join(' ')
73
- debug << "Options: #{request.options}"
74
- debug << "Headers: #{request.headers}"
75
73
  debug << "Response Code: #{response.code} (#{response.options[:return_code]})"
76
- debug << "Response Options: #{response.options}"
77
74
  debug << response.body
78
75
  debug << _message
79
76
 
@@ -12,7 +12,7 @@ class DHC::Interceptor
12
12
  @request.response
13
13
  end
14
14
 
15
- def before_raw_request; end
15
+ def before_init; end
16
16
 
17
17
  def before_request; end
18
18
 
@@ -4,7 +4,7 @@ class DHC::Auth < DHC::Interceptor
4
4
  include ActiveSupport::Configurable
5
5
  config_accessor :refresh_client_token
6
6
 
7
- def before_raw_request
7
+ def before_init
8
8
  body_authentication! if auth_options[:body]
9
9
  end
10
10
 
@@ -8,7 +8,7 @@ class DHC::DefaultTimeout < DHC::Interceptor
8
8
  CONNECTTIMEOUT = 2 # seconds
9
9
  TIMEOUT = 15 # seconds
10
10
 
11
- def before_raw_request
11
+ def before_init
12
12
  request_options = (request.options || {})
13
13
  request_options[:timeout] ||= timeout || TIMEOUT
14
14
  request_options[:connecttimeout] ||= connecttimeout || CONNECTTIMEOUT
@@ -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, (Rails.cache.read(CACHE_KEY) || {}).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/request.rb CHANGED
@@ -22,7 +22,7 @@ class DHC::Request
22
22
  use_configured_endpoint!
23
23
  generate_url_from_template!
24
24
  self.interceptors = DHC::Interceptors.new(self)
25
- interceptors.intercept(:before_raw_request)
25
+ interceptors.intercept(:before_init)
26
26
  self.raw = create_request
27
27
  interceptors.intercept(:before_request)
28
28
  if self_executing && !response
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 ||= '1.0.0'
4
+ VERSION ||= '2.1.1'
5
5
  end
@@ -19,6 +19,6 @@ describe Hash do
19
19
  end
20
20
 
21
21
  it 'applies upcase to all values' do
22
- expect(subject.deep_transform_values { |value| value.upcase }).to eq(expected_result)
22
+ expect(subject.deep_transform_values(&:upcase)).to eq(expected_result)
23
23
  end
24
24
  end
@@ -72,10 +72,7 @@ describe DHC::Error do
72
72
  it 'produces correct debug output' do
73
73
  expect(subject.to_s.split("\n")).to eq(<<-MSG.strip_heredoc.split("\n"))
74
74
  GET http://example.com/sessions
75
- Options: {:followlocation=>true, :auth=>{:bearer=>"aaaaaaaa-bbbb-cccc-dddd-eeee"}, :params=>{:limit=>20}, :url=>"http://example.com/sessions"}
76
- Headers: {"Bearer Token"=>"aaaaaaaa-bbbb-cccc-dddd-eeee"}
77
75
  Response Code: 500 (internal_error)
78
- Response Options: {:return_code=>:internal_error, :response_headers=>""}
79
76
  {"status":500,"message":"undefined"}
80
77
  The error message
81
78
  MSG
@@ -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,101 @@
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
+
83
+ context 'multiple provider' do
84
+
85
+ it 'tracks multiple providers without a problem' do
86
+ DHC.get('http://depay.fi', options)
87
+ stub_request(:get, 'http://depay.app').to_return(status: 200)
88
+ DHC.get('http://depay.app', {
89
+ throttle: {
90
+ provider: 'depay.app',
91
+ track: true,
92
+ limit: quota_limit,
93
+ expires: 1.minute,
94
+ break: break_after
95
+ }
96
+ })
97
+ expect(Rails.cache.read('DHC/throttle/tracker/v1')['depay.fi']).to be_present
98
+ expect(Rails.cache.read('DHC/throttle/tracker/v1')['depay.app']).to be_present
99
+ end
100
+ end
101
+ 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: 1.0.0
4
+ version: 2.1.1
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-03-31 00:00:00.000000000 Z
11
+ date: 2021-12-16 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
@@ -391,7 +392,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
391
392
  version: '0'
392
393
  requirements:
393
394
  - Ruby >= 2.0.0
394
- rubygems_version: 3.2.3
395
+ rubygems_version: 3.2.22
395
396
  signing_key:
396
397
  specification_version: 4
397
398
  summary: Advanced HTTP Client for Ruby, fueled with interceptors
@@ -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