httpx 1.3.4 → 1.4.1

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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/1_4_0.md +43 -0
  3. data/doc/release_notes/1_4_1.md +19 -0
  4. data/lib/httpx/adapters/datadog.rb +55 -83
  5. data/lib/httpx/adapters/faraday.rb +2 -0
  6. data/lib/httpx/adapters/webmock.rb +18 -6
  7. data/lib/httpx/callbacks.rb +0 -5
  8. data/lib/httpx/chainable.rb +3 -1
  9. data/lib/httpx/connection/http2.rb +12 -8
  10. data/lib/httpx/connection.rb +192 -22
  11. data/lib/httpx/errors.rb +12 -0
  12. data/lib/httpx/loggable.rb +5 -5
  13. data/lib/httpx/options.rb +26 -16
  14. data/lib/httpx/plugins/aws_sigv4.rb +31 -16
  15. data/lib/httpx/plugins/callbacks.rb +12 -2
  16. data/lib/httpx/plugins/circuit_breaker.rb +0 -5
  17. data/lib/httpx/plugins/content_digest.rb +202 -0
  18. data/lib/httpx/plugins/expect.rb +4 -3
  19. data/lib/httpx/plugins/follow_redirects.rb +7 -8
  20. data/lib/httpx/plugins/grpc/grpc_encoding.rb +2 -0
  21. data/lib/httpx/plugins/h2c.rb +23 -20
  22. data/lib/httpx/plugins/internal_telemetry.rb +27 -0
  23. data/lib/httpx/plugins/persistent.rb +16 -0
  24. data/lib/httpx/plugins/proxy/http.rb +17 -19
  25. data/lib/httpx/plugins/proxy.rb +91 -93
  26. data/lib/httpx/plugins/retries.rb +5 -8
  27. data/lib/httpx/plugins/upgrade.rb +5 -10
  28. data/lib/httpx/plugins/webdav.rb +6 -0
  29. data/lib/httpx/plugins/xml.rb +76 -0
  30. data/lib/httpx/pool.rb +73 -244
  31. data/lib/httpx/request/body.rb +25 -26
  32. data/lib/httpx/request.rb +7 -1
  33. data/lib/httpx/resolver/https.rb +15 -20
  34. data/lib/httpx/resolver/multi.rb +34 -16
  35. data/lib/httpx/resolver/native.rb +66 -25
  36. data/lib/httpx/resolver/resolver.rb +59 -15
  37. data/lib/httpx/resolver/system.rb +31 -15
  38. data/lib/httpx/resolver.rb +21 -14
  39. data/lib/httpx/response.rb +5 -3
  40. data/lib/httpx/selector.rb +160 -95
  41. data/lib/httpx/session.rb +273 -140
  42. data/lib/httpx/transcoder/body.rb +15 -31
  43. data/lib/httpx/transcoder/gzip.rb +0 -3
  44. data/lib/httpx/transcoder/json.rb +14 -2
  45. data/lib/httpx/transcoder/multipart/part.rb +1 -1
  46. data/lib/httpx/transcoder/utils/deflater.rb +7 -4
  47. data/lib/httpx/transcoder/utils/inflater.rb +2 -0
  48. data/lib/httpx/transcoder.rb +0 -1
  49. data/lib/httpx/version.rb +1 -1
  50. data/lib/httpx.rb +20 -21
  51. data/sig/callbacks.rbs +0 -1
  52. data/sig/chainable.rbs +4 -0
  53. data/sig/connection/http2.rbs +1 -1
  54. data/sig/connection.rbs +29 -3
  55. data/sig/errors.rbs +6 -0
  56. data/sig/loggable.rbs +2 -0
  57. data/sig/options.rbs +7 -0
  58. data/sig/plugins/aws_sigv4.rbs +8 -2
  59. data/sig/plugins/content_digest.rbs +51 -0
  60. data/sig/plugins/cookies/cookie.rbs +9 -0
  61. data/sig/plugins/grpc/call.rbs +4 -0
  62. data/sig/plugins/persistent.rbs +4 -1
  63. data/sig/plugins/proxy/socks5.rbs +11 -3
  64. data/sig/plugins/proxy.rbs +18 -11
  65. data/sig/plugins/push_promise.rbs +3 -0
  66. data/sig/plugins/rate_limiter.rbs +2 -0
  67. data/sig/plugins/retries.rbs +1 -1
  68. data/sig/plugins/ssrf_filter.rbs +26 -0
  69. data/sig/plugins/webdav.rbs +23 -0
  70. data/sig/plugins/xml.rbs +37 -0
  71. data/sig/pool.rbs +25 -33
  72. data/sig/request/body.rbs +5 -9
  73. data/sig/resolver/multi.rbs +26 -1
  74. data/sig/resolver/native.rbs +2 -2
  75. data/sig/resolver/resolver.rbs +21 -2
  76. data/sig/resolver.rbs +5 -1
  77. data/sig/response/buffer.rbs +1 -1
  78. data/sig/selector.rbs +30 -4
  79. data/sig/session.rbs +47 -18
  80. data/sig/transcoder/body.rbs +2 -4
  81. data/sig/transcoder/chunker.rbs +1 -1
  82. data/sig/transcoder/deflate.rbs +1 -0
  83. data/sig/transcoder/form.rbs +8 -0
  84. data/sig/transcoder/gzip.rbs +4 -1
  85. data/sig/transcoder/utils/body_reader.rbs +3 -3
  86. data/sig/transcoder/utils/deflater.rbs +3 -3
  87. metadata +12 -4
  88. data/lib/httpx/transcoder/xml.rb +0 -52
  89. data/sig/transcoder/xml.rbs +0 -22
@@ -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
@@ -29,6 +29,8 @@ module HTTPX
29
29
 
30
30
  buf = outbuf if outbuf
31
31
 
32
+ buf = buf.b if buf.frozen?
33
+
32
34
  buf.prepend([compressed_flag, buf.bytesize].pack("CL>"))
33
35
  buf
34
36
  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)