httpx 1.3.4 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/1_4_0.md +43 -0
  3. data/lib/httpx/adapters/faraday.rb +2 -0
  4. data/lib/httpx/adapters/webmock.rb +11 -5
  5. data/lib/httpx/callbacks.rb +0 -5
  6. data/lib/httpx/chainable.rb +3 -1
  7. data/lib/httpx/connection/http2.rb +11 -7
  8. data/lib/httpx/connection.rb +128 -16
  9. data/lib/httpx/errors.rb +12 -0
  10. data/lib/httpx/loggable.rb +5 -5
  11. data/lib/httpx/options.rb +26 -16
  12. data/lib/httpx/plugins/aws_sigv4.rb +31 -16
  13. data/lib/httpx/plugins/callbacks.rb +12 -2
  14. data/lib/httpx/plugins/circuit_breaker.rb +0 -5
  15. data/lib/httpx/plugins/content_digest.rb +202 -0
  16. data/lib/httpx/plugins/expect.rb +4 -3
  17. data/lib/httpx/plugins/follow_redirects.rb +7 -8
  18. data/lib/httpx/plugins/h2c.rb +23 -20
  19. data/lib/httpx/plugins/internal_telemetry.rb +27 -0
  20. data/lib/httpx/plugins/persistent.rb +16 -0
  21. data/lib/httpx/plugins/proxy/http.rb +17 -19
  22. data/lib/httpx/plugins/proxy.rb +91 -93
  23. data/lib/httpx/plugins/retries.rb +5 -8
  24. data/lib/httpx/plugins/upgrade.rb +5 -10
  25. data/lib/httpx/plugins/webdav.rb +6 -0
  26. data/lib/httpx/plugins/xml.rb +76 -0
  27. data/lib/httpx/pool.rb +73 -244
  28. data/lib/httpx/request/body.rb +16 -12
  29. data/lib/httpx/request.rb +1 -1
  30. data/lib/httpx/resolver/https.rb +12 -19
  31. data/lib/httpx/resolver/multi.rb +34 -16
  32. data/lib/httpx/resolver/native.rb +36 -13
  33. data/lib/httpx/resolver/resolver.rb +49 -11
  34. data/lib/httpx/resolver/system.rb +29 -11
  35. data/lib/httpx/resolver.rb +21 -14
  36. data/lib/httpx/response.rb +5 -3
  37. data/lib/httpx/selector.rb +164 -95
  38. data/lib/httpx/session.rb +296 -139
  39. data/lib/httpx/transcoder/gzip.rb +0 -3
  40. data/lib/httpx/transcoder/json.rb +14 -2
  41. data/lib/httpx/transcoder/utils/deflater.rb +7 -4
  42. data/lib/httpx/transcoder/utils/inflater.rb +2 -0
  43. data/lib/httpx/transcoder.rb +0 -1
  44. data/lib/httpx/version.rb +1 -1
  45. data/lib/httpx.rb +19 -20
  46. data/sig/callbacks.rbs +0 -1
  47. data/sig/chainable.rbs +4 -0
  48. data/sig/connection/http2.rbs +1 -1
  49. data/sig/connection.rbs +14 -3
  50. data/sig/errors.rbs +6 -0
  51. data/sig/loggable.rbs +2 -0
  52. data/sig/options.rbs +7 -0
  53. data/sig/plugins/aws_sigv4.rbs +8 -2
  54. data/sig/plugins/content_digest.rbs +51 -0
  55. data/sig/plugins/cookies/cookie.rbs +9 -0
  56. data/sig/plugins/grpc/call.rbs +4 -0
  57. data/sig/plugins/persistent.rbs +4 -1
  58. data/sig/plugins/proxy/socks5.rbs +11 -3
  59. data/sig/plugins/proxy.rbs +18 -11
  60. data/sig/plugins/push_promise.rbs +3 -0
  61. data/sig/plugins/rate_limiter.rbs +2 -0
  62. data/sig/plugins/retries.rbs +1 -1
  63. data/sig/plugins/ssrf_filter.rbs +26 -0
  64. data/sig/plugins/webdav.rbs +23 -0
  65. data/sig/plugins/xml.rbs +37 -0
  66. data/sig/pool.rbs +25 -33
  67. data/sig/request/body.rbs +5 -1
  68. data/sig/resolver/multi.rbs +26 -1
  69. data/sig/resolver/native.rbs +0 -2
  70. data/sig/resolver/resolver.rbs +21 -2
  71. data/sig/resolver.rbs +5 -1
  72. data/sig/response/buffer.rbs +1 -1
  73. data/sig/selector.rbs +30 -4
  74. data/sig/session.rbs +45 -18
  75. data/sig/transcoder/body.rbs +1 -1
  76. data/sig/transcoder/chunker.rbs +1 -1
  77. data/sig/transcoder/deflate.rbs +1 -0
  78. data/sig/transcoder/form.rbs +8 -0
  79. data/sig/transcoder/gzip.rbs +4 -1
  80. data/sig/transcoder/utils/body_reader.rbs +2 -2
  81. data/sig/transcoder/utils/deflater.rbs +2 -2
  82. metadata +10 -4
  83. data/lib/httpx/transcoder/xml.rb +0 -52
  84. data/sig/transcoder/xml.rbs +0 -22
@@ -89,7 +89,7 @@ module HTTPX
89
89
  sts = "#{algo_line}" \
90
90
  "\n#{datetime}" \
91
91
  "\n#{credential_scope}" \
92
- "\n#{hexdigest(creq)}"
92
+ "\n#{OpenSSL::Digest.new(@algorithm).hexdigest(creq)}"
93
93
 
94
94
  # signature
95
95
  k_date = hmac("#{upper_provider_prefix}#{@credentials.password}", date)
@@ -110,22 +110,38 @@ module HTTPX
110
110
  private
111
111
 
112
112
  def hexdigest(value)
113
- if value.respond_to?(:to_path)
114
- # files, pathnames
115
- OpenSSL::Digest.new(@algorithm).file(value.to_path).hexdigest
116
- elsif value.respond_to?(:each)
117
- digest = OpenSSL::Digest.new(@algorithm)
118
-
119
- mb_buffer = value.each.with_object("".b) do |chunk, buffer|
120
- buffer << chunk
121
- break if buffer.bytesize >= 1024 * 1024
122
- end
113
+ digest = OpenSSL::Digest.new(@algorithm)
123
114
 
124
- digest.update(mb_buffer)
125
- value.rewind
126
- digest.hexdigest
115
+ if value.respond_to?(:read)
116
+ if value.respond_to?(:to_path)
117
+ # files, pathnames
118
+ digest.file(value.to_path).hexdigest
119
+ else
120
+ # gzipped request bodies
121
+ raise Error, "request body must be rewindable" unless value.respond_to?(:rewind)
122
+
123
+ buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
124
+ begin
125
+ IO.copy_stream(value, buffer)
126
+ buffer.flush
127
+
128
+ digest.file(buffer.to_path).hexdigest
129
+ ensure
130
+ value.rewind
131
+ buffer.close
132
+ buffer.unlink
133
+ end
134
+ end
127
135
  else
128
- OpenSSL::Digest.new(@algorithm).hexdigest(value)
136
+ # error on endless generators
137
+ raise Error, "hexdigest for endless enumerators is not supported" if value.unbounded_body?
138
+
139
+ mb_buffer = value.each.with_object("".b) do |chunk, b|
140
+ b << chunk
141
+ break if b.bytesize >= 1024 * 1024
142
+ end
143
+
144
+ digest.hexdigest(mb_buffer)
129
145
  end
130
146
  end
131
147
 
@@ -142,7 +158,6 @@ module HTTPX
142
158
  def load_dependencies(*)
143
159
  require "set"
144
160
  require "digest/sha2"
145
- require "openssl"
146
161
  end
147
162
 
148
163
  def configure(klass)
@@ -31,12 +31,16 @@ module HTTPX
31
31
 
32
32
  private
33
33
 
34
- def init_connection(uri, options)
35
- connection = super
34
+ def do_init_connection(connection, selector)
35
+ super
36
36
  connection.on(:open) do
37
+ next unless connection.current_session == self
38
+
37
39
  emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
38
40
  end
39
41
  connection.on(:close) do
42
+ next unless connection.current_session == self
43
+
40
44
  emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
41
45
  end
42
46
 
@@ -84,6 +88,12 @@ module HTTPX
84
88
  rescue CallbackError => e
85
89
  raise e.cause
86
90
  end
91
+
92
+ def close(*)
93
+ super
94
+ rescue CallbackError => e
95
+ raise e.cause
96
+ end
87
97
  end
88
98
  end
89
99
  register_plugin :callbacks, Callbacks
@@ -32,11 +32,6 @@ module HTTPX
32
32
  @circuit_store = CircuitStore.new(@options)
33
33
  end
34
34
 
35
- def initialize_dup(orig)
36
- super
37
- @circuit_store = orig.instance_variable_get(:@circuit_store).dup
38
- end
39
-
40
35
  %i[circuit_open].each do |meth|
41
36
  class_eval(<<-MOD, __FILE__, __LINE__ + 1)
42
37
  def on_#{meth}(&blk) # def on_circuit_open(&blk)
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin adds `Content-Digest` headers to requests
7
+ # and can validate these headers on responses
8
+ #
9
+ # https://datatracker.ietf.org/doc/html/rfc9530
10
+ #
11
+ module ContentDigest
12
+ class Error < HTTPX::Error; end
13
+
14
+ # Error raised on response "content-digest" header validation.
15
+ class ValidationError < Error
16
+ attr_reader :response
17
+
18
+ def initialize(message, response)
19
+ super(message)
20
+ @response = response
21
+ end
22
+ end
23
+
24
+ class MissingContentDigestError < ValidationError; end
25
+ class InvalidContentDigestError < ValidationError; end
26
+
27
+ SUPPORTED_ALGORITHMS = {
28
+ "sha-256" => OpenSSL::Digest::SHA256,
29
+ "sha-512" => OpenSSL::Digest::SHA512,
30
+ }.freeze
31
+
32
+ class << self
33
+ def extra_options(options)
34
+ options.merge(encode_content_digest: true, validate_content_digest: false, content_digest_algorithm: "sha-256")
35
+ end
36
+ end
37
+
38
+ # add support for the following options:
39
+ #
40
+ # :content_digest_algorithm :: the digest algorithm to use. Currently supports `sha-256` and `sha-512`. (defaults to `sha-256`)
41
+ # :encode_content_digest :: whether a <tt>Content-Digest</tt> header should be computed for the request;
42
+ # can also be a callable object (i.e. <tt>->(req) { ... }</tt>, defaults to <tt>true</tt>)
43
+ # :validate_content_digest :: whether a <tt>Content-Digest</tt> header in the response should be validated;
44
+ # can also be a callable object (i.e. <tt>->(res) { ... }</tt>, defaults to <tt>false</tt>)
45
+ module OptionsMethods
46
+ def option_content_digest_algorithm(value)
47
+ raise TypeError, ":content_digest_algorithm must be one of 'sha-256', 'sha-512'" unless SUPPORTED_ALGORITHMS.key?(value)
48
+
49
+ value
50
+ end
51
+
52
+ def option_encode_content_digest(value)
53
+ value
54
+ end
55
+
56
+ def option_validate_content_digest(value)
57
+ value
58
+ end
59
+ end
60
+
61
+ module ResponseBodyMethods
62
+ attr_reader :content_digest_buffer
63
+
64
+ def initialize(response, options)
65
+ super
66
+
67
+ return unless response.headers.key?("content-digest")
68
+
69
+ should_validate = options.validate_content_digest
70
+ should_validate = should_validate.call(response) if should_validate.respond_to?(:call)
71
+
72
+ return unless should_validate
73
+
74
+ @content_digest_buffer = Response::Buffer.new(
75
+ threshold_size: @options.body_threshold_size,
76
+ bytesize: @length,
77
+ encoding: @encoding
78
+ )
79
+ end
80
+
81
+ def write(chunk)
82
+ @content_digest_buffer.write(chunk) if @content_digest_buffer
83
+ super
84
+ end
85
+
86
+ def close
87
+ if @content_digest_buffer
88
+ @content_digest_buffer.close
89
+ @content_digest_buffer = nil
90
+ end
91
+ super
92
+ end
93
+ end
94
+
95
+ module InstanceMethods
96
+ def build_request(*)
97
+ request = super
98
+
99
+ return request if request.empty?
100
+
101
+ return request if request.headers.key?("content-digest")
102
+
103
+ perform_encoding = @options.encode_content_digest
104
+ perform_encoding = perform_encoding.call(request) if perform_encoding.respond_to?(:call)
105
+
106
+ return request unless perform_encoding
107
+
108
+ digest = base64digest(request.body)
109
+ request.headers.add("content-digest", "#{@options.content_digest_algorithm}=:#{digest}:")
110
+
111
+ request
112
+ end
113
+
114
+ private
115
+
116
+ def fetch_response(request, _, _)
117
+ response = super
118
+ return response unless response.is_a?(Response)
119
+
120
+ perform_validation = @options.validate_content_digest
121
+ perform_validation = perform_validation.call(response) if perform_validation.respond_to?(:call)
122
+
123
+ validate_content_digest(response) if perform_validation
124
+
125
+ response
126
+ rescue ValidationError => e
127
+ ErrorResponse.new(request, e)
128
+ end
129
+
130
+ def validate_content_digest(response)
131
+ content_digest_header = response.headers["content-digest"]
132
+
133
+ raise MissingContentDigestError.new("response is missing a `content-digest` header", response) unless content_digest_header
134
+
135
+ digests = extract_content_digests(content_digest_header)
136
+
137
+ included_algorithms = SUPPORTED_ALGORITHMS.keys & digests.keys
138
+
139
+ raise MissingContentDigestError.new("unsupported algorithms: #{digests.keys.join(", ")}", response) if included_algorithms.empty?
140
+
141
+ content_buffer = response.body.content_digest_buffer
142
+
143
+ included_algorithms.each do |algorithm|
144
+ digest = SUPPORTED_ALGORITHMS.fetch(algorithm).new
145
+ digest_received = digests[algorithm]
146
+ digest_computed =
147
+ if content_buffer.respond_to?(:to_path)
148
+ content_buffer.flush
149
+ digest.file(content_buffer.to_path).base64digest
150
+ else
151
+ digest.base64digest(content_buffer.to_s)
152
+ end
153
+
154
+ raise InvalidContentDigestError.new("#{algorithm} digest does not match content",
155
+ response) unless digest_received == digest_computed
156
+ end
157
+ end
158
+
159
+ def extract_content_digests(header)
160
+ header.split(",").to_h do |entry|
161
+ algorithm, digest = entry.split("=", 2)
162
+ raise Error, "#{entry} is an invalid digest format" unless algorithm && digest
163
+
164
+ [algorithm, digest.byteslice(1..-2)]
165
+ end
166
+ end
167
+
168
+ def base64digest(body)
169
+ digest = SUPPORTED_ALGORITHMS.fetch(@options.content_digest_algorithm).new
170
+
171
+ if body.respond_to?(:read)
172
+ if body.respond_to?(:to_path)
173
+ digest.file(body.to_path).base64digest
174
+ else
175
+ raise ContentDigestError, "request body must be rewindable" unless body.respond_to?(:rewind)
176
+
177
+ buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
178
+ begin
179
+ IO.copy_stream(body, buffer)
180
+ buffer.flush
181
+
182
+ digest.file(buffer.to_path).base64digest
183
+ ensure
184
+ body.rewind
185
+ buffer.close
186
+ buffer.unlink
187
+ end
188
+ end
189
+ else
190
+ raise ContentDigestError, "base64digest for endless enumerators is not supported" if body.unbounded_body?
191
+
192
+ buffer = "".b
193
+ body.each { |chunk| buffer << chunk }
194
+
195
+ digest.base64digest(buffer)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ register_plugin :content_digest, ContentDigest
201
+ end
202
+ end
@@ -96,15 +96,16 @@ module HTTPX
96
96
  end
97
97
 
98
98
  module InstanceMethods
99
- def fetch_response(request, connections, options)
100
- response = @responses.delete(request)
99
+ def fetch_response(request, selector, options)
100
+ response = super
101
+
101
102
  return unless response
102
103
 
103
104
  if response.is_a?(Response) && response.status == 417 && request.headers.key?("expect")
104
105
  response.close
105
106
  request.headers.delete("expect")
106
107
  request.transition(:idle)
107
- send_request(request, connections, options)
108
+ send_request(request, selector, options)
108
109
  return
109
110
  end
110
111
 
@@ -64,9 +64,9 @@ module HTTPX
64
64
 
65
65
  private
66
66
 
67
- def fetch_response(request, connections, options)
67
+ def fetch_response(request, selector, options)
68
68
  redirect_request = request.redirect_request
69
- response = super(redirect_request, connections, options)
69
+ response = super(redirect_request, selector, options)
70
70
  return unless response
71
71
 
72
72
  max_redirects = redirect_request.max_redirects
@@ -146,20 +146,19 @@ module HTTPX
146
146
  #
147
147
  redirect_after = Utils.parse_retry_after(redirect_after)
148
148
 
149
+ retry_start = Utils.now
149
150
  log { "redirecting after #{redirect_after} secs..." }
150
-
151
- deactivate_connection(request, connections, options)
152
-
153
- pool.after(redirect_after) do
151
+ selector.after(redirect_after) do
154
152
  if request.response
155
153
  # request has terminated abruptly meanwhile
156
154
  retry_request.emit(:response, request.response)
157
155
  else
158
- send_request(retry_request, connections, options)
156
+ log { "redirecting (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
157
+ send_request(retry_request, selector, options)
159
158
  end
160
159
  end
161
160
  else
162
- send_request(retry_request, connections, options)
161
+ send_request(retry_request, selector, options)
163
162
  end
164
163
  nil
165
164
  end
@@ -25,26 +25,6 @@ module HTTPX
25
25
  end
26
26
  end
27
27
 
28
- module InstanceMethods
29
- def send_requests(*requests)
30
- upgrade_request, *remainder = requests
31
-
32
- return super unless VALID_H2C_VERBS.include?(upgrade_request.verb) && upgrade_request.scheme == "http"
33
-
34
- connection = pool.find_connection(upgrade_request.uri, upgrade_request.options)
35
-
36
- return super if connection && connection.upgrade_protocol == "h2c"
37
-
38
- # build upgrade request
39
- upgrade_request.headers.add("connection", "upgrade")
40
- upgrade_request.headers.add("connection", "http2-settings")
41
- upgrade_request.headers["upgrade"] = "h2c"
42
- upgrade_request.headers["http2-settings"] = ::HTTP2::Client.settings_header(upgrade_request.options.http2_settings)
43
-
44
- super(upgrade_request, *remainder)
45
- end
46
- end
47
-
48
28
  class H2CParser < Connection::HTTP2
49
29
  def upgrade(request, response)
50
30
  # skip checks, it is assumed that this is the first
@@ -65,6 +45,29 @@ module HTTPX
65
45
  module ConnectionMethods
66
46
  using URIExtensions
67
47
 
48
+ def initialize(*)
49
+ super
50
+ @h2c_handshake = false
51
+ end
52
+
53
+ def send(request)
54
+ return super if @h2c_handshake
55
+
56
+ return super unless VALID_H2C_VERBS.include?(request.verb) && request.scheme == "http"
57
+
58
+ return super if @upgrade_protocol == "h2c"
59
+
60
+ @h2c_handshake = true
61
+
62
+ # build upgrade request
63
+ request.headers.add("connection", "upgrade")
64
+ request.headers.add("connection", "http2-settings")
65
+ request.headers["upgrade"] = "h2c"
66
+ request.headers["http2-settings"] = ::HTTP2::Client.settings_header(request.options.http2_settings)
67
+
68
+ super
69
+ end
70
+
68
71
  def upgrade_to_h2c(request, response)
69
72
  prev_parser = @parser
70
73
 
@@ -76,6 +76,14 @@ module HTTPX
76
76
  meter_elapsed_time("Session -> response") if response
77
77
  response
78
78
  end
79
+
80
+ def coalesce_connections(conn1, conn2, selector, *)
81
+ result = super
82
+
83
+ meter_elapsed_time("Connection##{conn2.object_id} coalescing to Connection##{conn1.object_id}") if result
84
+
85
+ result
86
+ end
79
87
  end
80
88
 
81
89
  module RequestMethods
@@ -103,6 +111,25 @@ module HTTPX
103
111
  meter_elapsed_time("Connection##{object_id}[#{@origin}]: #{state} -> #{nextstate}") if nextstate == @state
104
112
  end
105
113
  end
114
+
115
+ module PoolMethods
116
+ def self.included(klass)
117
+ klass.prepend TrackTimeMethods
118
+ super
119
+ end
120
+
121
+ def checkout_connection(request_uri, options)
122
+ super.tap do |connection|
123
+ meter_elapsed_time("Pool##{object_id}: checked out connection for Connection##{connection.object_id}[#{connection.origin}]}")
124
+ end
125
+ end
126
+
127
+ def checkin_connection(connection)
128
+ super.tap do
129
+ meter_elapsed_time("Pool##{object_id}: checked in connection for Connection##{connection.object_id}[#{connection.origin}]}")
130
+ end
131
+ end
132
+ end
106
133
  end
107
134
  register_plugin :internal_telemetry, InternalTelemetry
108
135
  end
@@ -30,6 +30,22 @@ module HTTPX
30
30
  def self.extra_options(options)
31
31
  options.merge(persistent: true)
32
32
  end
33
+
34
+ module InstanceMethods
35
+ private
36
+
37
+ def get_current_selector
38
+ super(&nil) || begin
39
+ return unless block_given?
40
+
41
+ default = yield
42
+
43
+ set_current_selector(default)
44
+
45
+ default
46
+ end
47
+ end
48
+ end
33
49
  end
34
50
  register_plugin :persistent, Persistent
35
51
  end
@@ -23,29 +23,19 @@ module HTTPX
23
23
  with(proxy: opts.merge(scheme: "ntlm"))
24
24
  end
25
25
 
26
- def fetch_response(request, connections, options)
26
+ def fetch_response(request, selector, options)
27
27
  response = super
28
28
 
29
29
  if response &&
30
30
  response.is_a?(Response) &&
31
31
  response.status == 407 &&
32
32
  !request.headers.key?("proxy-authorization") &&
33
- response.headers.key?("proxy-authenticate")
34
-
35
- uri = request.uri
36
-
37
- proxy_options = proxy_options(uri, options)
38
- connection = connections.find do |conn|
39
- conn.match?(uri, proxy_options)
40
- end
41
-
42
- if connection && connection.options.proxy.can_authenticate?(response.headers["proxy-authenticate"])
43
- request.transition(:idle)
44
- request.headers["proxy-authorization"] =
45
- connection.options.proxy.authenticate(request, response.headers["proxy-authenticate"])
46
- send_request(request, connections)
47
- return
48
- end
33
+ response.headers.key?("proxy-authenticate") && options.proxy.can_authenticate?(response.headers["proxy-authenticate"])
34
+ request.transition(:idle)
35
+ request.headers["proxy-authorization"] =
36
+ options.proxy.authenticate(request, response.headers["proxy-authenticate"])
37
+ send_request(request, selector, options)
38
+ return
49
39
  end
50
40
 
51
41
  response
@@ -74,7 +64,14 @@ module HTTPX
74
64
  parser = @parser
75
65
  parser.extend(ProxyParser)
76
66
  parser.on(:response, &method(:__http_on_connect))
77
- parser.on(:close) { transition(:closing) }
67
+ parser.on(:close) do |force|
68
+ next unless @parser
69
+
70
+ if force
71
+ reset
72
+ emit(:terminate)
73
+ end
74
+ end
78
75
  parser.on(:reset) do
79
76
  if parser.empty?
80
77
  reset
@@ -95,8 +92,9 @@ module HTTPX
95
92
 
96
93
  case @state
97
94
  when :connecting
98
- @parser.close
95
+ parser = @parser
99
96
  @parser = nil
97
+ parser.close
100
98
  when :idle
101
99
  @parser.callbacks.clear
102
100
  set_parser_callbacks(@parser)