http 5.2.0 → 5.3.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: 8593353cd2283b2ac49c557ff71d5f1a668066dbac59b3d2a6f59429b9b2f5e8
4
- data.tar.gz: e9316b3c4dd519825eeaa255a4f86932ce4a9e7f374f9be8273ca4051f5b9c3d
3
+ metadata.gz: da85aeba1d2d3dce86f59a678ade3f74df35514397cb7488f9aaab9ea5a57220
4
+ data.tar.gz: 28e5143890592f55f51fa65874f787f3c3e9eac5f9f6103c589082414eff378e
5
5
  SHA512:
6
- metadata.gz: 0ae34b411a233ca8601aebe2295f2a1d8e0bec7e742a95fdd6e075b4f7dd3c68d42448cfbac1146b645ee59424e91af8eb3878c43e7a7fb17c3569681cf4ccf3
7
- data.tar.gz: a76bc0a41338e67677370014d803ea79e3a34078172cc81491f31b085613394c7290b2402e199a2e1de77c7fecf7e05fde8fc2d9924016105ce429751f202e85
6
+ metadata.gz: 6b14bb800aefa920d2511b962967253fc034847bb07f8d181bb9cec6dba136ca0c51655fd0da716986897be5ef83e6023705578016db7bbd12bbd1f2ed4bbeb4
7
+ data.tar.gz: dc996c20d358b382fbb2b1d93af7a706f858167392c8db3d8bf11e114419c0c7ed0a61350753f9c13422ca48b469af0b359f80694fe423066a02439066bfdd07
@@ -0,0 +1,9 @@
1
+ RSpec/ExampleLength:
2
+ CountAsOne:
3
+ - array
4
+ - hash
5
+ - heredoc
6
+ - method_call
7
+
8
+ RSpec/MultipleExpectations:
9
+ Max: 5
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 100`
3
- # on 2022-06-16 14:35:44 UTC using RuboCop version 1.30.1.
3
+ # on 2025-06-09 02:44:43 UTC using RuboCop version 1.30.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -14,7 +14,7 @@ Gemspec/DeprecatedAttributeAssignment:
14
14
  Exclude:
15
15
  - 'http.gemspec'
16
16
 
17
- # Offense count: 53
17
+ # Offense count: 55
18
18
  # This cop supports safe autocorrection (--autocorrect).
19
19
  # Configuration parameters: EnforcedStyle.
20
20
  # SupportedStyles: leading, trailing
@@ -30,7 +30,14 @@ Layout/DotPosition:
30
30
  - 'spec/lib/http_spec.rb'
31
31
  - 'spec/support/http_handling_shared.rb'
32
32
 
33
- # Offense count: 176
33
+ # Offense count: 2
34
+ # This cop supports safe autocorrection (--autocorrect).
35
+ # Configuration parameters: IndentationWidth.
36
+ # SupportedStyles: special_inside_parentheses, consistent, align_braces
37
+ Layout/FirstHashElementIndentation:
38
+ EnforcedStyle: consistent
39
+
40
+ # Offense count: 206
34
41
  # This cop supports safe autocorrection (--autocorrect).
35
42
  # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces.
36
43
  # SupportedStyles: space, no_space, compact
@@ -62,6 +69,18 @@ Lint/MissingSuper:
62
69
  - 'lib/http/features/logging.rb'
63
70
  - 'lib/http/features/normalize_uri.rb'
64
71
 
72
+ # Offense count: 1
73
+ # This cop supports safe autocorrection (--autocorrect).
74
+ Lint/RedundantCopDisableDirective:
75
+ Exclude:
76
+ - 'spec/lib/http/retriable/performer_spec.rb'
77
+
78
+ # Offense count: 6
79
+ # Configuration parameters: AllowComments, AllowNil.
80
+ Lint/SuppressedException:
81
+ Exclude:
82
+ - 'spec/lib/http/retriable/performer_spec.rb'
83
+
65
84
  # Offense count: 8
66
85
  # Configuration parameters: IgnoredMethods, CountRepeatedAttributes, Max.
67
86
  Metrics/AbcSize:
@@ -74,34 +93,6 @@ Metrics/AbcSize:
74
93
  - 'lib/http/request.rb'
75
94
  - 'lib/http/response.rb'
76
95
 
77
- # Offense count: 70
78
- # Configuration parameters: CountComments, Max, CountAsOne, ExcludedMethods, IgnoredMethods.
79
- # IgnoredMethods: refine
80
- Metrics/BlockLength:
81
- Exclude:
82
- - '**/*.gemspec'
83
- - 'spec/lib/http/client_spec.rb'
84
- - 'spec/lib/http/connection_spec.rb'
85
- - 'spec/lib/http/content_type_spec.rb'
86
- - 'spec/lib/http/features/auto_deflate_spec.rb'
87
- - 'spec/lib/http/features/auto_inflate_spec.rb'
88
- - 'spec/lib/http/features/instrumentation_spec.rb'
89
- - 'spec/lib/http/features/logging_spec.rb'
90
- - 'spec/lib/http/headers/mixin_spec.rb'
91
- - 'spec/lib/http/headers_spec.rb'
92
- - 'spec/lib/http/options/merge_spec.rb'
93
- - 'spec/lib/http/redirector_spec.rb'
94
- - 'spec/lib/http/request/body_spec.rb'
95
- - 'spec/lib/http/request/writer_spec.rb'
96
- - 'spec/lib/http/request_spec.rb'
97
- - 'spec/lib/http/response/body_spec.rb'
98
- - 'spec/lib/http/response/parser_spec.rb'
99
- - 'spec/lib/http/response/status_spec.rb'
100
- - 'spec/lib/http/response_spec.rb'
101
- - 'spec/lib/http/uri_spec.rb'
102
- - 'spec/lib/http_spec.rb'
103
- - 'spec/support/http_handling_shared.rb'
104
-
105
96
  # Offense count: 4
106
97
  # Configuration parameters: CountComments, Max, CountAsOne.
107
98
  Metrics/ClassLength:
@@ -118,7 +109,7 @@ Metrics/CyclomaticComplexity:
118
109
  - 'lib/http/chainable.rb'
119
110
  - 'lib/http/client.rb'
120
111
 
121
- # Offense count: 18
112
+ # Offense count: 19
122
113
  # Configuration parameters: CountComments, Max, CountAsOne, ExcludedMethods, IgnoredMethods.
123
114
  Metrics/MethodLength:
124
115
  Exclude:
@@ -133,6 +124,7 @@ Metrics/MethodLength:
133
124
  - 'lib/http/request.rb'
134
125
  - 'lib/http/response.rb'
135
126
  - 'lib/http/response/body.rb'
127
+ - 'lib/http/retriable/performer.rb'
136
128
  - 'lib/http/timeout/global.rb'
137
129
 
138
130
  # Offense count: 1
@@ -173,6 +165,27 @@ Style/Encoding:
173
165
  - 'spec/lib/http_spec.rb'
174
166
  - 'spec/support/dummy_server/servlet.rb'
175
167
 
168
+ # Offense count: 71
169
+ # This cop supports safe autocorrection (--autocorrect).
170
+ # Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols.
171
+ # SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys
172
+ # SupportedShorthandSyntax: always, never, either
173
+ Style/HashSyntax:
174
+ Exclude:
175
+ - 'spec/lib/http/features/raise_error_spec.rb'
176
+ - 'spec/lib/http/retriable/delay_calculator_spec.rb'
177
+ - 'spec/lib/http/retriable/performer_spec.rb'
178
+ - 'spec/lib/http_spec.rb'
179
+
180
+ # Offense count: 4
181
+ # This cop supports unsafe autocorrection (--autocorrect-all).
182
+ # Configuration parameters: EnforcedStyle.
183
+ # SupportedStyles: literals, strict
184
+ Style/MutableConstant:
185
+ Exclude:
186
+ - 'lib/http/headers/normalizer.rb'
187
+ - 'lib/http/retriable/delay_calculator.rb'
188
+
176
189
  # Offense count: 17
177
190
  # Configuration parameters: SuspiciousParamNames, Allowlist.
178
191
  # SuspiciousParamNames: options, opts, args, params, parameters
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.3.0] - 2025-06-09
11
+
12
+ ### Added
13
+
14
+ - (backported) Add .retriable feature to Http
15
+ - (backported) Add more specific ConnectionError classes
16
+ - (backported) New feature: RaiseError
17
+
18
+ ### Changed
19
+
20
+ - (backported) Drop depenency on base64
21
+ - (backported) Cache header normalization to reduce object allocation
22
+ - (backported) Use native llhttp on MRI
23
+
24
+
10
25
  ## [5.2.0] - 2024-02-05
11
26
 
12
27
  ### Added
@@ -37,5 +52,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
52
  - Prevent CRLF injection due to broken URL normalizer
38
53
  ([#765](https://github.com/httprb/http/pull/765))
39
54
 
40
- [unreleased]: https://github.com/httprb/http/compare/v5.2.0...5-x-stable
55
+ [unreleased]: https://github.com/httprb/http/compare/v5.3.0...5-x-stable
56
+ [5.3.0]: https://github.com/httprb/http/compare/v5.2.0...v5.3.0
41
57
  [5.2.0]: https://github.com/httprb/http/compare/v5.1.1...v5.2.0
data/Gemfile CHANGED
@@ -37,6 +37,7 @@ group :test do
37
37
 
38
38
  gem "rspec", "~> 3.10"
39
39
  gem "rspec-its"
40
+ gem "rspec-memory"
40
41
 
41
42
  gem "yardstick"
42
43
  end
data/http.gemspec CHANGED
@@ -28,10 +28,15 @@ Gem::Specification.new do |gem|
28
28
  gem.required_ruby_version = ">= 2.6"
29
29
 
30
30
  gem.add_runtime_dependency "addressable", "~> 2.8"
31
- gem.add_runtime_dependency "base64", "~> 0.1"
32
31
  gem.add_runtime_dependency "http-cookie", "~> 1.0"
33
32
  gem.add_runtime_dependency "http-form_data", "~> 2.2"
34
- gem.add_runtime_dependency "llhttp-ffi", "~> 0.5.0"
33
+
34
+ # Use native llhttp for MRI (more performant) and llhttp-ffi for other interpreters (better compatibility)
35
+ if RUBY_ENGINE == "ruby"
36
+ gem.add_runtime_dependency "llhttp", "~> 0.5.0"
37
+ else
38
+ gem.add_runtime_dependency "llhttp-ffi", "~> 0.5.0"
39
+ end
35
40
 
36
41
  gem.add_development_dependency "bundler", "~> 2.0"
37
42
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Base64
5
+ module_function
6
+
7
+ # Equivalent to Base64.strict_encode64
8
+ def encode64(input)
9
+ [input].pack("m0")
10
+ end
11
+ end
12
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
-
3
+ require "http/base64"
5
4
  require "http/headers"
6
5
 
7
6
  module HTTP
8
7
  module Chainable
8
+ include HTTP::Base64
9
+
9
10
  # Request a get sans response body
10
11
  # @param uri
11
12
  # @option options [Hash]
@@ -215,7 +216,7 @@ module HTTP
215
216
  pass = opts.fetch(:pass)
216
217
  creds = "#{user}:#{pass}"
217
218
 
218
- auth("Basic #{Base64.strict_encode64(creds)}")
219
+ auth("Basic #{encode64(creds)}")
219
220
  end
220
221
 
221
222
  # Get options for HTTP
@@ -242,11 +243,34 @@ module HTTP
242
243
  # * instrumentation
243
244
  # * logging
244
245
  # * normalize_uri
246
+ # * raise_error
245
247
  # @param features
246
248
  def use(*features)
247
249
  branch default_options.with_features(features)
248
250
  end
249
251
 
252
+ # Returns retriable client instance, which retries requests if they failed
253
+ # due to some socket errors or response status is `5xx`.
254
+ #
255
+ # @example Usage
256
+ #
257
+ # # Retry max 5 times with randomly growing delay between retries
258
+ # HTTP.retriable.get(url)
259
+ #
260
+ # # Retry max 3 times with randomly growing delay between retries
261
+ # HTTP.retriable(times: 3).get(url)
262
+ #
263
+ # # Retry max 3 times with 1 sec delay between retries
264
+ # HTTP.retriable(times: 3, delay: proc { 1 }).get(url)
265
+ #
266
+ # # Retry max 3 times with geometrically progressed delay between retries
267
+ # HTTP.retriable(times: 3, delay: proc { |i| 1 + i*i }).get(url)
268
+ #
269
+ # @param (see Performer#initialize)
270
+ def retriable(**options)
271
+ Retriable::Client.new(Retriable::Performer.new(options), default_options)
272
+ end
273
+
250
274
  private
251
275
 
252
276
  # :nodoc:
@@ -105,10 +105,11 @@ module HTTP
105
105
 
106
106
  # Reads data from socket up until headers are loaded
107
107
  # @return [void]
108
+ # @raise [ResponseHeaderError] when unable to read response headers
108
109
  def read_headers!
109
110
  until @parser.headers?
110
111
  result = read_more(BUFFER_SIZE)
111
- raise ConnectionError, "couldn't read response headers" if result == :eof
112
+ raise ResponseHeaderError, "couldn't read response headers" if result == :eof
112
113
  end
113
114
 
114
115
  set_keep_alive
@@ -217,6 +218,7 @@ module HTTP
217
218
 
218
219
  # Feeds some more data into parser
219
220
  # @return [void]
221
+ # @raise [SocketReadError] when unable to read from socket
220
222
  def read_more(size)
221
223
  return if @parser.finished?
222
224
 
@@ -228,7 +230,7 @@ module HTTP
228
230
  @parser << value
229
231
  end
230
232
  rescue IOError, SocketError, SystemCallError => e
231
- raise ConnectionError, "error reading from socket: #{e}", e.backtrace
233
+ raise SocketReadError, "error reading from socket: #{e}", e.backtrace
232
234
  end
233
235
  end
234
236
  end
data/lib/http/errors.rb CHANGED
@@ -7,6 +7,11 @@ module HTTP
7
7
  # Generic Connection error
8
8
  class ConnectionError < Error; end
9
9
 
10
+ # Types of Connection errors
11
+ class ResponseHeaderError < ConnectionError; end
12
+ class SocketReadError < ConnectionError; end
13
+ class SocketWriteError < ConnectionError; end
14
+
10
15
  # Generic Request error
11
16
  class RequestError < Error; end
12
17
 
@@ -16,6 +21,17 @@ module HTTP
16
21
  # Requested to do something when we're in the wrong state
17
22
  class StateError < ResponseError; end
18
23
 
24
+ # When status code indicates an error
25
+ class StatusError < ResponseError
26
+ attr_reader :response
27
+
28
+ def initialize(response)
29
+ @response = response
30
+
31
+ super("Unexpected status code #{response.code}")
32
+ end
33
+ end
34
+
19
35
  # Generic Timeout error
20
36
  class TimeoutError < Error; end
21
37
 
data/lib/http/feature.rb CHANGED
@@ -20,6 +20,7 @@ end
20
20
 
21
21
  require "http/features/auto_inflate"
22
22
  require "http/features/auto_deflate"
23
- require "http/features/logging"
24
23
  require "http/features/instrumentation"
24
+ require "http/features/logging"
25
25
  require "http/features/normalize_uri"
26
+ require "http/features/raise_error"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ class RaiseError < Feature
6
+ def initialize(ignore: [])
7
+ super()
8
+
9
+ @ignore = ignore
10
+ end
11
+
12
+ def wrap_response(response)
13
+ return response if response.code < 400
14
+ return response if @ignore.include?(response.code)
15
+
16
+ raise HTTP::StatusError, response
17
+ end
18
+
19
+ HTTP::Options.register_feature(:raise_error, self)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ class Headers
5
+ class Normalizer
6
+ # Matches HTTP header names when in "Canonical-Http-Format"
7
+ CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/
8
+
9
+ # Matches valid header field name according to RFC.
10
+ # @see http://tools.ietf.org/html/rfc7230#section-3.2
11
+ COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/
12
+
13
+ NAME_PARTS_SEPARATOR_RE = /[\-_]/
14
+
15
+ # @private
16
+ # Normalized header names cache
17
+ class Cache
18
+ MAX_SIZE = 200
19
+
20
+ def initialize
21
+ @store = {}
22
+ end
23
+
24
+ def get(key)
25
+ @store[key]
26
+ end
27
+ alias [] get
28
+
29
+ def set(key, value)
30
+ # Maintain cache size
31
+ @store.delete(@store.each_key.first) while MAX_SIZE <= @store.size
32
+
33
+ @store[key] = value
34
+ end
35
+ alias []= set
36
+ end
37
+
38
+ def initialize
39
+ @cache = Cache.new
40
+ end
41
+
42
+ # Transforms `name` to canonical HTTP header capitalization
43
+ def call(name)
44
+ name = -name.to_s
45
+ value = (@cache[name] ||= -normalize_header(name))
46
+
47
+ value.dup
48
+ end
49
+
50
+ private
51
+
52
+ # Transforms `name` to canonical HTTP header capitalization
53
+ #
54
+ # @param [String] name
55
+ # @raise [HeaderError] if normalized name does not
56
+ # match {COMPLIANT_NAME_RE}
57
+ # @return [String] canonical HTTP header name
58
+ def normalize_header(name)
59
+ return name if CANONICAL_NAME_RE.match?(name)
60
+
61
+ normalized = name.split(NAME_PARTS_SEPARATOR_RE).each(&:capitalize!).join("-")
62
+
63
+ return normalized if COMPLIANT_NAME_RE.match?(normalized)
64
+
65
+ raise HeaderError, "Invalid HTTP header field name: #{name.inspect}"
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/http/headers.rb CHANGED
@@ -4,6 +4,7 @@ require "forwardable"
4
4
 
5
5
  require "http/errors"
6
6
  require "http/headers/mixin"
7
+ require "http/headers/normalizer"
7
8
  require "http/headers/known"
8
9
 
9
10
  module HTTP
@@ -12,12 +13,31 @@ module HTTP
12
13
  extend Forwardable
13
14
  include Enumerable
14
15
 
15
- # Matches HTTP header names when in "Canonical-Http-Format"
16
- CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/.freeze
16
+ class << self
17
+ # Coerces given `object` into Headers.
18
+ #
19
+ # @raise [Error] if object can't be coerced
20
+ # @param [#to_hash, #to_h, #to_a] object
21
+ # @return [Headers]
22
+ def coerce(object)
23
+ unless object.is_a? self
24
+ object = case
25
+ when object.respond_to?(:to_hash) then object.to_hash
26
+ when object.respond_to?(:to_h) then object.to_h
27
+ when object.respond_to?(:to_a) then object.to_a
28
+ else raise Error, "Can't coerce #{object.inspect} to Headers"
29
+ end
30
+ end
31
+ headers = new
32
+ object.each { |k, v| headers.add k, v }
33
+ headers
34
+ end
35
+ alias [] coerce
17
36
 
18
- # Matches valid header field name according to RFC.
19
- # @see http://tools.ietf.org/html/rfc7230#section-3.2
20
- COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/.freeze
37
+ def normalizer
38
+ @normalizer ||= Headers::Normalizer.new
39
+ end
40
+ end
21
41
 
22
42
  # Class constructor.
23
43
  def initialize
@@ -194,45 +214,11 @@ module HTTP
194
214
  dup.tap { |dupped| dupped.merge! other }
195
215
  end
196
216
 
197
- class << self
198
- # Coerces given `object` into Headers.
199
- #
200
- # @raise [Error] if object can't be coerced
201
- # @param [#to_hash, #to_h, #to_a] object
202
- # @return [Headers]
203
- def coerce(object)
204
- unless object.is_a? self
205
- object = case
206
- when object.respond_to?(:to_hash) then object.to_hash
207
- when object.respond_to?(:to_h) then object.to_h
208
- when object.respond_to?(:to_a) then object.to_a
209
- else raise Error, "Can't coerce #{object.inspect} to Headers"
210
- end
211
- end
212
-
213
- headers = new
214
- object.each { |k, v| headers.add k, v }
215
- headers
216
- end
217
- alias [] coerce
218
- end
219
-
220
217
  private
221
218
 
222
219
  # Transforms `name` to canonical HTTP header capitalization
223
- #
224
- # @param [String] name
225
- # @raise [HeaderError] if normalized name does not
226
- # match {HEADER_NAME_RE}
227
- # @return [String] canonical HTTP header name
228
220
  def normalize_header(name)
229
- return name if name =~ CANONICAL_NAME_RE
230
-
231
- normalized = name.split(/[\-_]/).each(&:capitalize!).join("-")
232
-
233
- return normalized if normalized =~ COMPLIANT_NAME_RE
234
-
235
- raise HeaderError, "Invalid HTTP header field name: #{name.inspect}"
221
+ self.class.normalizer.call(name)
236
222
  end
237
223
 
238
224
  # Ensures there is no new line character in the header value
@@ -108,6 +108,7 @@ module HTTP
108
108
 
109
109
  private
110
110
 
111
+ # @raise [SocketWriteError] when unable to write to socket
111
112
  def write(data)
112
113
  until data.empty?
113
114
  length = @socket.write(data)
@@ -118,7 +119,7 @@ module HTTP
118
119
  rescue Errno::EPIPE
119
120
  raise
120
121
  rescue IOError, SocketError, SystemCallError => e
121
- raise ConnectionError, "error writing to socket: #{e}", e.backtrace
122
+ raise SocketWriteError, "error writing to socket: #{e}", e.backtrace
122
123
  end
123
124
  end
124
125
  end
data/lib/http/request.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require "base64"
5
4
  require "time"
6
5
 
6
+ require "http/base64"
7
7
  require "http/errors"
8
8
  require "http/headers"
9
9
  require "http/request/body"
@@ -15,6 +15,7 @@ module HTTP
15
15
  class Request
16
16
  extend Forwardable
17
17
 
18
+ include HTTP::Base64
18
19
  include HTTP::Headers::Mixin
19
20
 
20
21
  # The method given was not understood
@@ -159,7 +160,7 @@ module HTTP
159
160
  end
160
161
 
161
162
  def proxy_authorization_header
162
- digest = Base64.strict_encode64("#{proxy[:proxy_username]}:#{proxy[:proxy_password]}")
163
+ digest = encode64("#{proxy[:proxy_username]}:#{proxy[:proxy_password]}")
163
164
  "Basic #{digest}"
164
165
  end
165
166
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http/retriable/performer"
4
+
5
+ module HTTP
6
+ module Retriable
7
+ # Retriable version of HTTP::Client.
8
+ #
9
+ # @see http://www.rubydoc.info/gems/http/HTTP/Client
10
+ class Client < HTTP::Client
11
+ # @param [Performer] performer
12
+ # @param [HTTP::Options, Hash] options
13
+ def initialize(performer, options)
14
+ @performer = performer
15
+ super(options)
16
+ end
17
+
18
+ # Overriden version of `HTTP::Client#make_request`.
19
+ #
20
+ # Monitors request/response phase with performer.
21
+ #
22
+ # @see http://www.rubydoc.info/gems/http/HTTP/Client:perform
23
+ def perform(req, options)
24
+ @performer.perform(self, req) { super(req, options) }
25
+ end
26
+
27
+ private
28
+
29
+ # Overriden version of `HTTP::Chainable#branch`.
30
+ #
31
+ # @return [HTTP::Retriable::Client]
32
+ def branch(options)
33
+ Retriable::Client.new(@performer, options)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Retriable
5
+ # @api private
6
+ class DelayCalculator
7
+ def initialize(opts)
8
+ @max_delay = opts.fetch(:max_delay, Float::MAX).to_f
9
+ if (delay = opts[:delay]).respond_to?(:call)
10
+ @delay_proc = opts.fetch(:delay)
11
+ else
12
+ @delay = delay
13
+ end
14
+ end
15
+
16
+ def call(iteration, response)
17
+ delay = if response && (retry_header = response.headers["Retry-After"])
18
+ delay_from_retry_header(retry_header)
19
+ else
20
+ calculate_delay_from_iteration(iteration)
21
+ end
22
+
23
+ ensure_dealy_in_bounds(delay)
24
+ end
25
+
26
+ RFC2822_DATE_REGEX = /^
27
+ (?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s+
28
+ (?:0[1-9]|[1-2]?[0-9]|3[01])\s+
29
+ (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+
30
+ (?:19[0-9]{2}|[2-9][0-9]{3})\s+
31
+ (?:2[0-3]|[0-1][0-9]):(?:[0-5][0-9]):(?:60|[0-5][0-9])\s+
32
+ GMT
33
+ $/x
34
+
35
+ # Spec for Retry-After header
36
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
37
+ def delay_from_retry_header(value)
38
+ value = value.to_s.strip
39
+
40
+ case value
41
+ when RFC2822_DATE_REGEX then DateTime.rfc2822(value).to_time - Time.now.utc
42
+ when /^\d+$/ then value.to_i
43
+ else 0
44
+ end
45
+ end
46
+
47
+ def calculate_delay_from_iteration(iteration)
48
+ if @delay_proc
49
+ @delay_proc.call(iteration)
50
+ elsif @delay
51
+ @delay
52
+ else
53
+ delay = (2**(iteration - 1)) - 1
54
+ delay_noise = rand
55
+ delay + delay_noise
56
+ end
57
+ end
58
+
59
+ def ensure_dealy_in_bounds(delay)
60
+ delay.clamp(0, @max_delay)
61
+ end
62
+ end
63
+ end
64
+ end