httpx 1.8.3 → 1.9.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/1_8_4.md +13 -0
  3. data/doc/release_notes/1_9_0.md +75 -0
  4. data/lib/httpx/adapters/webmock.rb +1 -1
  5. data/lib/httpx/altsvc.rb +29 -15
  6. data/lib/httpx/callbacks.rb +2 -2
  7. data/lib/httpx/chainable.rb +6 -3
  8. data/lib/httpx/connection/http1.rb +11 -19
  9. data/lib/httpx/connection/http2.rb +112 -27
  10. data/lib/httpx/connection.rb +184 -46
  11. data/lib/httpx/headers.rb +2 -2
  12. data/lib/httpx/io/tcp.rb +18 -14
  13. data/lib/httpx/options.rb +86 -35
  14. data/lib/httpx/parser/http1.rb +3 -6
  15. data/lib/httpx/plugins/auth/digest.rb +1 -2
  16. data/lib/httpx/plugins/auth.rb +11 -2
  17. data/lib/httpx/plugins/aws_sdk_authentication.rb +7 -3
  18. data/lib/httpx/plugins/aws_sigv4.rb +1 -0
  19. data/lib/httpx/plugins/basic_auth.rb +2 -0
  20. data/lib/httpx/plugins/callbacks.rb +7 -6
  21. data/lib/httpx/plugins/circuit_breaker.rb +1 -0
  22. data/lib/httpx/plugins/cookies/cookie.rb +1 -1
  23. data/lib/httpx/plugins/cookies.rb +2 -2
  24. data/lib/httpx/plugins/digest_auth.rb +2 -0
  25. data/lib/httpx/plugins/expect.rb +2 -0
  26. data/lib/httpx/plugins/fiber_concurrency.rb +6 -0
  27. data/lib/httpx/plugins/follow_redirects.rb +8 -6
  28. data/lib/httpx/plugins/grpc/call.rb +4 -4
  29. data/lib/httpx/plugins/grpc/grpc_encoding.rb +2 -2
  30. data/lib/httpx/plugins/grpc.rb +21 -0
  31. data/lib/httpx/plugins/hedging.rb +284 -0
  32. data/lib/httpx/plugins/ntlm_auth.rb +1 -0
  33. data/lib/httpx/plugins/ntlm_v2_auth.rb +1 -0
  34. data/lib/httpx/plugins/oauth.rb +1 -1
  35. data/lib/httpx/plugins/proxy/http.rb +71 -16
  36. data/lib/httpx/plugins/proxy/ssh.rb +30 -11
  37. data/lib/httpx/plugins/proxy.rb +66 -45
  38. data/lib/httpx/plugins/push_promise.rb +4 -2
  39. data/lib/httpx/plugins/retries.rb +13 -3
  40. data/lib/httpx/plugins/server_sent_events.rb +2 -1
  41. data/lib/httpx/plugins/ssrf_filter.rb +1 -1
  42. data/lib/httpx/plugins/stream.rb +2 -2
  43. data/lib/httpx/plugins/tracing.rb +61 -14
  44. data/lib/httpx/plugins/upgrade/h2.rb +1 -1
  45. data/lib/httpx/plugins/upgrade.rb +2 -0
  46. data/lib/httpx/plugins/webdav.rb +8 -0
  47. data/lib/httpx/request/body.rb +2 -1
  48. data/lib/httpx/request.rb +10 -7
  49. data/lib/httpx/resolver/multi.rb +2 -2
  50. data/lib/httpx/resolver/resolver.rb +2 -2
  51. data/lib/httpx/resolver.rb +5 -1
  52. data/lib/httpx/response/body.rb +2 -2
  53. data/lib/httpx/response.rb +19 -11
  54. data/lib/httpx/selector.rb +3 -3
  55. data/lib/httpx/session.rb +38 -11
  56. data/lib/httpx/transcoder/chunker.rb +2 -2
  57. data/lib/httpx/transcoder/json.rb +7 -7
  58. data/lib/httpx/version.rb +1 -1
  59. data/sig/altsvc.rbs +3 -3
  60. data/sig/callbacks.rbs +1 -0
  61. data/sig/chainable.rbs +1 -0
  62. data/sig/connection/http1.rbs +1 -1
  63. data/sig/connection/http2.rbs +30 -16
  64. data/sig/connection.rbs +19 -1
  65. data/sig/headers.rbs +1 -1
  66. data/sig/httpx.rbs +2 -0
  67. data/sig/options.rbs +43 -12
  68. data/sig/parser/http1.rbs +1 -1
  69. data/sig/plugins/auth/digest.rbs +0 -1
  70. data/sig/plugins/auth.rbs +12 -5
  71. data/sig/plugins/hedging.rbs +44 -0
  72. data/sig/plugins/proxy/http.rbs +8 -1
  73. data/sig/plugins/proxy.rbs +3 -2
  74. data/sig/plugins/retries.rbs +2 -2
  75. data/sig/plugins/tracing.rbs +14 -5
  76. data/sig/request/body.rbs +1 -1
  77. data/sig/request.rbs +2 -1
  78. data/sig/resolver/entry.rbs +2 -2
  79. data/sig/response.rbs +1 -2
  80. data/sig/selector.rbs +2 -2
  81. data/sig/session.rbs +7 -5
  82. metadata +7 -1
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin adds support for request hedging, i.e. a latency-mitigation strategy that involves
7
+ # sending requests through multiple connections and returning the fastest response.
8
+ #
9
+ # https://gitlab.com/os85/httpx/wikis/Hedging#hedging
10
+ #
11
+ module Hedging
12
+ # :hedge_after:: seconds after which, if the request didn't complete yet, it'll be sent on a different connection
13
+ module OptionsMethods
14
+ private
15
+
16
+ def option_hedge_after(value)
17
+ if value.respond_to?(:call)
18
+ value1 = value
19
+ value1 = value1.method(:call) unless value1.respond_to?(:arity)
20
+
21
+ # allow ->(*) arity as well, which is < 0
22
+ raise TypeError, "`:hedge_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
23
+ 1, 2
24
+ )
25
+
26
+ else
27
+ value = Float(value)
28
+ raise TypeError, "`:hedge_after` must be positive" unless value.positive?
29
+ end
30
+
31
+ value
32
+ end
33
+ end
34
+
35
+ module InstanceMethods
36
+ def select_connection(connection, selector)
37
+ connection.try_yield_hedge_connection do |hedge_conn|
38
+ super.tap do
39
+ select_connection(hedge_conn, selector) if hedge_conn
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def do_request(request, connection, selector)
47
+ super.tap do
48
+ # forego hedging if the connection is already open, or if
49
+ # the connection is scheduled for DNS resolution, as pending requests
50
+ # will be hedged afterwards.
51
+ next if connection.open? || !connection.addresses?
52
+
53
+ hedge_request!(request, connection, selector)
54
+ end
55
+ end
56
+
57
+ # creates the hedge connection before a connection initiates the connection handshake.
58
+ # This excludes connections which are already open.
59
+ def on_resolver_connection(connection, selector)
60
+ super.tap do
61
+ next if connection.open? || connection.hedge_connection
62
+
63
+ connection.pending.each do |request|
64
+ hedge_request!(request, connection, selector)
65
+ end
66
+
67
+ on_resolver_connection(connection.hedge_connection, selector) if connection.hedge_connection
68
+ end
69
+ end
70
+
71
+ def hedge_connection(connection)
72
+ connection.hedge_connection || begin
73
+ new_connection = connection.dup
74
+ new_connection.log { "cloning hedge connection of connection##{connection.object_id}..." }
75
+ connection.hedge_connection = new_connection
76
+ new_connection
77
+ end
78
+ end
79
+
80
+ def hedge_request!(request, connection, selector)
81
+ hedge_after = when_to_hedge(request)
82
+
83
+ if hedge_after&.positive?
84
+ # hedge after interval
85
+ cb = selector.after(hedge_after) do
86
+ next if request.response
87
+
88
+ request.hedge_request = hedge_request = request.dup
89
+ hedge_conn = hedge_connection(connection)
90
+ hedge_conn.send(hedge_request)
91
+ select_connection(hedge_conn, selector)
92
+ end
93
+
94
+ # forego hedging if request is over
95
+ request.once(:response) do
96
+ cb.cancel
97
+ end
98
+ else
99
+ request.hedge_request = hedge_request = request.dup
100
+ hedge_connection(connection).send(hedge_request)
101
+ end
102
+ end
103
+
104
+ def when_to_hedge(request)
105
+ hedge_after = request.options.hedge_after
106
+
107
+ return unless hedge_after
108
+
109
+ hedge_after = hedge_after.call(request) if hedge_after.respond_to?(:call)
110
+
111
+ hedge_after
112
+ end
113
+
114
+ # hedge connections should not deactivate, instead they should terminate.
115
+ # this ensures that persistent hedge connections aren't treated as persistent,
116
+ # and close instead of deactivating.
117
+ def deactivate(selector, *)
118
+ hedge_connections = selector.each_connection.select(&:is_hedge_connection)
119
+
120
+ return super unless hedge_connections.any?
121
+
122
+ selector_close(selector, hedge_connections)
123
+
124
+ super
125
+ end
126
+
127
+ # do not check hedge connections back in
128
+ def can_checkin?(connection)
129
+ super && !connection.is_hedge_connection
130
+ end
131
+ end
132
+
133
+ module RequestMethods
134
+ # TODO: would be nice to get rid of this
135
+ attr_reader :connection
136
+
137
+ attr_reader :hedge_request
138
+
139
+ def initialize(*)
140
+ super
141
+ @is_hedge_request = false
142
+ end
143
+
144
+ def hedge_request=(request)
145
+ @hedge_request = request
146
+
147
+ return unless request
148
+
149
+ @is_hedge_request = !request.hedge_request.nil?
150
+
151
+ return if @is_hedge_request
152
+
153
+ request.hedge_request = self
154
+ end
155
+
156
+ def response=(response)
157
+ return super unless @hedge_request # rubocop:disable Lint/ReturnInVoidContext
158
+
159
+ prev_response = @response
160
+
161
+ begin
162
+ super
163
+ ensure
164
+ @response = prev_response if prev_response
165
+ end
166
+ end
167
+
168
+ def emit_response(response)
169
+ hedge_request = @hedge_request
170
+
171
+ return super unless hedge_request
172
+
173
+ return super unless hedge_request &&
174
+ (
175
+ hedge_request.response.nil? ||
176
+ !hedge_request.response.finished?
177
+ )
178
+
179
+ # if this response is for a hedge request, and the main request
180
+ # didn't complete yet, then we replace it.
181
+
182
+ super.tap do
183
+ hedge_request.response = response
184
+
185
+ # should cancel any pending HTTP/2 stream; there's no way to
186
+ # cancel an HTTP/1 request, so that one will run to completion.
187
+ hedge_request.emit(:refuse)
188
+ end
189
+ ensure
190
+ self.hedge_request = nil
191
+ end
192
+ end
193
+
194
+ module ResponseMethods
195
+ def finish!
196
+ connection = @request.connection
197
+
198
+ super
199
+
200
+ return unless connection && (hedge_connection = connection.hedge_connection)
201
+
202
+ # disconnect connection with its hedge, so that objects do not leak after
203
+ # they're not needed.
204
+ connection.hedge_connection = hedge_connection.hedge_connection = nil
205
+
206
+ # remarking itself as the main connection, so that it's not discarded when
207
+ # going back to the pool.
208
+ connection.is_hedge_connection = false
209
+ hedge_connection.is_hedge_connection = true
210
+ end
211
+ end
212
+
213
+ module ConnectionMethods
214
+ attr_reader :hedge_connection
215
+
216
+ attr_accessor :is_hedge_connection
217
+
218
+ def initialize(*)
219
+ super
220
+ @is_hedge_connection = false
221
+ end
222
+
223
+ def addresses=(addrs)
224
+ try_yield_hedge_connection do |hedge_conn|
225
+ super.tap do
226
+ hedge_conn.addresses = addrs if hedge_conn
227
+ end
228
+ end
229
+ end
230
+
231
+ def hedge_connection=(connection)
232
+ @hedge_connection = connection
233
+
234
+ return unless connection
235
+
236
+ @is_hedge_connection = !connection.hedge_connection.nil?
237
+
238
+ return if @is_hedge_connection
239
+
240
+ connection.hedge_connection = self
241
+ end
242
+
243
+ def try_yield_hedge_connection
244
+ return yield(nil) unless (hedge_conn = @hedge_connection)
245
+
246
+ @hedge_connection = nil
247
+
248
+ begin
249
+ hedge_conn.try_yield_hedge_connection do
250
+ yield(hedge_conn)
251
+ end
252
+ ensure
253
+ @hedge_connection = hedge_conn
254
+ end
255
+ end
256
+
257
+ # connections should not merge with their hedge connections
258
+ def mergeable?(connection)
259
+ super && connection.hedge_connection != self
260
+ end
261
+
262
+ def send(request)
263
+ super.tap do
264
+ if @hedge_connection && !request.hedge_request
265
+ request.hedge_request = hedge_request = request.dup
266
+ @hedge_connection.send(hedge_request)
267
+ end
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ def send_request_to_parser(request)
274
+ # if original request was in the queue, but has since been finished
275
+ # in the hedge connection.
276
+ return if request.response&.finished?
277
+
278
+ super
279
+ end
280
+ end
281
+ end
282
+ register_plugin(:hedging, Hedging)
283
+ end
284
+ end
@@ -28,6 +28,7 @@ module HTTPX
28
28
  end
29
29
 
30
30
  module InstanceMethods
31
+ # returns a new session which authenticates requests using NTLM auth.
31
32
  def ntlm_auth(user, password, domain = nil)
32
33
  with(ntlm: Authentication::Ntlm.new(user, password, domain: domain))
33
34
  end
@@ -54,6 +54,7 @@ module HTTPX
54
54
  end
55
55
 
56
56
  module InstanceMethods
57
+ # returns a new session which authenticates requests using NTLM v2 auth.
57
58
  def ntlm_auth(user, password, domain = nil)
58
59
  with(ntlm: Authenticator.new(user, password, domain: domain))
59
60
  end
@@ -323,7 +323,7 @@ module HTTPX
323
323
  module InstanceMethods
324
324
  private
325
325
 
326
- def prepare_to_retry(_request, response)
326
+ def prepare_to_retry(*)
327
327
  @oauth_session.reset! if @oauth_session
328
328
 
329
329
  super
@@ -10,19 +10,44 @@ module HTTPX
10
10
  end
11
11
  end
12
12
 
13
+ # adds support for the following options:
14
+ #
15
+ # :proxy_headers :: hash of HTTP headers to send while connecting to the proxy.
16
+ # :enable_proxy_tunnel :: when <tt>true</tt>, it forces the establishment of the CONNECT proxy
17
+ # tunnel, in cases where it wouldn't otherwise normally establish it
18
+ # (i.e. if the request is an http:// request).
19
+ module OptionsMethods
20
+ private
21
+
22
+ def option_proxy_headers(value)
23
+ value = value.dup if value.frozen?
24
+
25
+ headers_class.new(value)
26
+ end
27
+
28
+ def option_enable_proxy_tunnel(value) # rubocop:disable Naming/PredicateMethod
29
+ !!value
30
+ end
31
+ end
32
+
13
33
  module InstanceMethods
34
+ # returns a new session with the proxy authentication scheme set to <tt>"basic"</tt>, for HTTP Basic Auth.
14
35
  def with_proxy_basic_auth(opts)
15
36
  with(proxy: opts.merge(scheme: "basic"))
16
37
  end
17
38
 
39
+ # returns a new session with the proxy authentication scheme set to <tt>"digest"</tt>, for HTTP Digest Auth.
18
40
  def with_proxy_digest_auth(opts)
19
41
  with(proxy: opts.merge(scheme: "digest"))
20
42
  end
21
43
 
44
+ # returns a new session with the proxy authentication scheme set to <tt>"ntlm"</tt>, for NTLM Auth.
22
45
  def with_proxy_ntlm_auth(opts)
23
46
  with(proxy: opts.merge(scheme: "ntlm"))
24
47
  end
25
48
 
49
+ private
50
+
26
51
  def fetch_response(request, selector, options)
27
52
  response = super
28
53
 
@@ -46,7 +71,12 @@ module HTTPX
46
71
  end
47
72
 
48
73
  module ConnectionMethods
49
- def force_close(*)
74
+ def initialize(*)
75
+ @tunnel_parser = nil
76
+ super
77
+ end
78
+
79
+ def handle_connect_error(*)
50
80
  if @state == :connecting
51
81
  # proxy connect related requests should not be reenqueed
52
82
  @parser.reset
@@ -57,8 +87,20 @@ module HTTPX
57
87
  super
58
88
  end
59
89
 
90
+ def on_error(error, *)
91
+ error = error.to_connection_error if error.is_a?(RequestTimeoutError) && connecting?
92
+
93
+ super
94
+ end
95
+
60
96
  private
61
97
 
98
+ def purge_after_closed
99
+ super
100
+
101
+ @tunnel_parser = nil
102
+ end
103
+
62
104
  def handle_transition(nextstate)
63
105
  return super unless @options.proxy && @options.proxy.uri.scheme == "http"
64
106
 
@@ -69,8 +111,15 @@ module HTTPX
69
111
  @io.connect
70
112
  return unless @io.connected?
71
113
 
72
- @parser || begin
73
- @parser = parser = parser_type(@io.protocol).new(@write_buffer, @options.merge(max_concurrent_requests: 1))
114
+ @tunnel_parser || begin
115
+ if @parser
116
+ # in case this happens, requests from @parser need to be reenqueued for delivery.
117
+ enqueue_pending_requests_from_parser(@parser)
118
+ @parser = nil
119
+ end
120
+
121
+ @tunnel_parser = @parser = parser =
122
+ parser_type(@io.protocol).new(@write_buffer, @options.merge(max_concurrent_requests: 1))
74
123
  parser.extend(ProxyParser)
75
124
  parser.on(:response, &method(:__http_on_connect))
76
125
  parser.on(:close) do
@@ -86,6 +135,9 @@ module HTTPX
86
135
 
87
136
  initial_state = @state
88
137
 
138
+ # because #reset will nillify @parser...
139
+ tunnel_parser = @parser
140
+
89
141
  reset
90
142
 
91
143
  if @pending.empty?
@@ -98,8 +150,8 @@ module HTTPX
98
150
  connect_request = parser = nil
99
151
 
100
152
  if initial_state == :connecting
101
- parser = @parser
102
- @parser.reset
153
+ parser = tunnel_parser
154
+ parser.reset
103
155
  if @pending.first.is_a?(ConnectRequest)
104
156
  connect_request = @pending.shift # this happened when reenqueing
105
157
  end
@@ -107,7 +159,7 @@ module HTTPX
107
159
 
108
160
  idling
109
161
 
110
- @parser = parser
162
+ @tunnel_parser = @parser = parser
111
163
  if connect_request
112
164
  @inflight += 1
113
165
  parser.send(connect_request)
@@ -115,7 +167,7 @@ module HTTPX
115
167
  transition(:connecting)
116
168
  end
117
169
  end
118
- __http_proxy_connect(parser)
170
+ __http_proxy_connect
119
171
  end
120
172
  return if @state == :connected
121
173
  when :connected
@@ -124,7 +176,7 @@ module HTTPX
124
176
  case @state
125
177
  when :connecting
126
178
  parser = @parser
127
- @parser = nil
179
+ @tunnel_parser = @parser = nil
128
180
  parser.close
129
181
  when :idle
130
182
  @parser.callbacks.clear
@@ -134,16 +186,19 @@ module HTTPX
134
186
  super
135
187
  end
136
188
 
137
- def __http_proxy_connect(parser)
189
+ def __http_proxy_connect
138
190
  req = @pending.first
139
- if req && req.uri.scheme == "https"
191
+ enable_proxy_tunnel = (
192
+ req.options.enable_proxy_tunnel.nil? && req && req.uri.scheme == "https"
193
+ ) || req.options.enable_proxy_tunnel
194
+
195
+ if enable_proxy_tunnel
140
196
  # if the first request after CONNECT is to an https address, it is assumed that
141
197
  # all requests in the queue are not only ALL HTTPS, but they also share the certificate,
142
198
  # and therefore, will share the connection.
143
199
  #
144
200
  connect_request = ConnectRequest.new(req.uri, @options)
145
- @inflight += 1
146
- parser.send(connect_request)
201
+ send(connect_request)
147
202
  else
148
203
  handle_transition(:connected)
149
204
  end
@@ -154,7 +209,7 @@ module HTTPX
154
209
  if response.is_a?(Response) && response.status == 200
155
210
  req = @pending.first
156
211
  request_uri = req.uri
157
- @io = ProxySSL.new(@io, request_uri, @options)
212
+ @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
158
213
  transition(:connected)
159
214
  throw(:called)
160
215
  elsif response.is_a?(Response) &&
@@ -164,8 +219,7 @@ module HTTPX
164
219
 
165
220
  request.transition(:idle)
166
221
  request.headers["proxy-authorization"] = @options.proxy.authenticate(request, response.headers["proxy-authenticate"])
167
- @parser.send(request)
168
- @inflight += 1
222
+ send(request)
169
223
  else
170
224
  pending = @pending + @parser.pending
171
225
  while (req = pending.shift)
@@ -201,7 +255,8 @@ module HTTPX
201
255
  class ConnectRequest < Request
202
256
  def initialize(uri, options)
203
257
  super("CONNECT", uri, options)
204
- @headers.delete("accept")
258
+ @headers = options.headers_class.new
259
+ merge_headers(options.proxy_headers) if options.proxy_headers
205
260
  end
206
261
 
207
262
  def path
@@ -21,31 +21,50 @@ module HTTPX
21
21
  end
22
22
 
23
23
  module InstanceMethods
24
+ def initialize(*)
25
+ super
26
+ @_gateway = nil
27
+ end
28
+
24
29
  def request(*args, **options)
25
30
  raise ArgumentError, "must perform at least one request" if args.empty?
26
31
 
27
32
  requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
28
33
 
29
- request = requests.first or return super
34
+ request = requests.first
35
+
36
+ return super unless request
30
37
 
31
38
  request_options = request.options
32
39
 
33
40
  return super unless request_options.proxy
34
41
 
35
- ssh_options = request_options.proxy
36
- ssh_uris = ssh_options.delete(:uri)
37
- ssh_uri = URI.parse(ssh_uris.shift)
42
+ ssh_options = request_options.proxy.dup
43
+ ssh_uris = Array(ssh_options.delete(:uri))
44
+
45
+ proxy_ns = 0
46
+
47
+ begin
48
+ ssh_uri = URI(ssh_uris[proxy_ns])
49
+
50
+ return super unless ssh_uri.scheme == "ssh"
51
+
52
+ ssh_username = ssh_options.delete(:username)
53
+ ssh_options[:port] ||= ssh_uri.port || 22
54
+ if request_options.debug
55
+ ssh_options[:verbose] = request_options.debug_level == 2 ? :debug : :info
56
+ end
38
57
 
39
- return super unless ssh_uri.scheme == "ssh"
58
+ request_uri = URI(requests.first.uri)
59
+ @_gateway = Net::SSH::Gateway.new(ssh_uri.host, ssh_username, ssh_options)
60
+ rescue StandardError => e
61
+ log { "failed connecting to ssh proxy, trying next..." }
62
+ proxy_ns += 1
63
+ raise e if proxy_ns >= ssh_uris.size
40
64
 
41
- ssh_username = ssh_options.delete(:username)
42
- ssh_options[:port] ||= ssh_uri.port || 22
43
- if request_options.debug
44
- ssh_options[:verbose] = request_options.debug_level == 2 ? :debug : :info
65
+ retry
45
66
  end
46
67
 
47
- request_uri = URI(requests.first.uri)
48
- @_gateway = Net::SSH::Gateway.new(ssh_uri.host, ssh_username, ssh_options)
49
68
  begin
50
69
  @_gateway.open(request_uri.host, request_uri.port) do |local_port|
51
70
  io = build_gateway_socket(local_port, request_uri, request_options)