httpx 0.10.2 → 0.12.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -5
  3. data/doc/release_notes/0_11_0.md +76 -0
  4. data/doc/release_notes/0_11_1.md +5 -0
  5. data/doc/release_notes/0_11_2.md +5 -0
  6. data/doc/release_notes/0_11_3.md +5 -0
  7. data/doc/release_notes/0_12_0.md +55 -0
  8. data/lib/httpx.rb +2 -1
  9. data/lib/httpx/adapters/datadog.rb +205 -0
  10. data/lib/httpx/adapters/faraday.rb +4 -8
  11. data/lib/httpx/adapters/webmock.rb +123 -0
  12. data/lib/httpx/altsvc.rb +1 -0
  13. data/lib/httpx/chainable.rb +1 -1
  14. data/lib/httpx/connection.rb +63 -15
  15. data/lib/httpx/connection/http1.rb +16 -5
  16. data/lib/httpx/connection/http2.rb +36 -29
  17. data/lib/httpx/domain_name.rb +1 -3
  18. data/lib/httpx/errors.rb +2 -0
  19. data/lib/httpx/headers.rb +1 -0
  20. data/lib/httpx/io.rb +16 -3
  21. data/lib/httpx/io/ssl.rb +7 -13
  22. data/lib/httpx/io/tcp.rb +9 -8
  23. data/lib/httpx/io/tls.rb +218 -0
  24. data/lib/httpx/io/tls/box.rb +365 -0
  25. data/lib/httpx/io/tls/context.rb +199 -0
  26. data/lib/httpx/io/tls/ffi.rb +390 -0
  27. data/lib/httpx/io/udp.rb +4 -3
  28. data/lib/httpx/parser/http1.rb +4 -4
  29. data/lib/httpx/plugins/aws_sdk_authentication.rb +81 -0
  30. data/lib/httpx/plugins/aws_sigv4.rb +218 -0
  31. data/lib/httpx/plugins/compression.rb +1 -1
  32. data/lib/httpx/plugins/compression/deflate.rb +2 -5
  33. data/lib/httpx/plugins/cookies/set_cookie_parser.rb +1 -1
  34. data/lib/httpx/plugins/expect.rb +33 -8
  35. data/lib/httpx/plugins/internal_telemetry.rb +93 -0
  36. data/lib/httpx/plugins/multipart.rb +42 -35
  37. data/lib/httpx/plugins/multipart/encoder.rb +110 -0
  38. data/lib/httpx/plugins/multipart/mime_type_detector.rb +64 -0
  39. data/lib/httpx/plugins/multipart/part.rb +34 -0
  40. data/lib/httpx/plugins/proxy.rb +1 -1
  41. data/lib/httpx/plugins/proxy/http.rb +1 -1
  42. data/lib/httpx/plugins/proxy/socks4.rb +8 -0
  43. data/lib/httpx/plugins/proxy/socks5.rb +11 -2
  44. data/lib/httpx/plugins/push_promise.rb +5 -4
  45. data/lib/httpx/plugins/retries.rb +1 -1
  46. data/lib/httpx/plugins/stream.rb +3 -5
  47. data/lib/httpx/pool.rb +0 -1
  48. data/lib/httpx/registry.rb +1 -7
  49. data/lib/httpx/request.rb +32 -12
  50. data/lib/httpx/resolver.rb +7 -4
  51. data/lib/httpx/resolver/https.rb +7 -13
  52. data/lib/httpx/resolver/native.rb +10 -6
  53. data/lib/httpx/resolver/system.rb +1 -1
  54. data/lib/httpx/response.rb +9 -2
  55. data/lib/httpx/selector.rb +6 -0
  56. data/lib/httpx/session.rb +40 -20
  57. data/lib/httpx/transcoder.rb +6 -4
  58. data/lib/httpx/transcoder/body.rb +3 -5
  59. data/lib/httpx/version.rb +1 -1
  60. data/sig/connection/http1.rbs +2 -2
  61. data/sig/connection/http2.rbs +8 -7
  62. data/sig/headers.rbs +3 -0
  63. data/sig/plugins/aws_sdk_authentication.rbs +17 -0
  64. data/sig/plugins/aws_sigv4.rbs +65 -0
  65. data/sig/plugins/multipart.rbs +27 -4
  66. data/sig/plugins/push_promise.rbs +1 -1
  67. data/sig/request.rbs +1 -1
  68. data/sig/resolver/https.rbs +2 -0
  69. data/sig/response.rbs +1 -1
  70. data/sig/session.rbs +1 -1
  71. data/sig/transcoder.rbs +2 -2
  72. data/sig/transcoder/body.rbs +2 -0
  73. data/sig/transcoder/form.rbs +7 -1
  74. data/sig/transcoder/json.rbs +3 -1
  75. metadata +50 -47
  76. data/sig/missing.rbs +0 -12
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebMock
4
+ module HttpLibAdapters
5
+ if RUBY_VERSION < "2.5"
6
+ require "webrick/httpstatus"
7
+ HTTP_REASONS = WEBrick::HTTPStatus::StatusMessage
8
+ else
9
+ require "net/http/status"
10
+ HTTP_REASONS = Net::HTTP::STATUS_CODES
11
+ end
12
+
13
+ #
14
+ # HTTPX plugin for webmock.
15
+ #
16
+ # Requests are "hijacked" at the session, before they're distributed to a connection.
17
+ #
18
+ module Plugin
19
+ module InstanceMethods
20
+ private
21
+
22
+ def send_requests(*requests, options)
23
+ request_signatures = requests.map do |request|
24
+ request_signature = _build_webmock_request_signature(request)
25
+ WebMock::RequestRegistry.instance.requested_signatures.put(request_signature)
26
+ request_signature
27
+ end
28
+
29
+ responses = request_signatures.map do |request_signature|
30
+ WebMock::StubRegistry.instance.response_for_request(request_signature)
31
+ end
32
+
33
+ real_requests = {}
34
+
35
+ requests.each_with_index.each_with_object([request_signatures, responses]) do |(request, idx), (sig_reqs, mock_responses)|
36
+ if (webmock_response = mock_responses[idx])
37
+ mock_responses[idx] = _build_from_webmock_response(request, webmock_response)
38
+ WebMock::CallbackRegistry.invoke_callbacks({ lib: :httpx }, sig_reqs[idx], webmock_response)
39
+ log { "mocking #{request.uri} with #{mock_responses[idx].inspect}" }
40
+ elsif WebMock.net_connect_allowed?(sig_reqs[idx].uri)
41
+ log { "performing #{request.uri}" }
42
+ real_requests[request] = idx
43
+ else
44
+ raise WebMock::NetConnectNotAllowedError, sig_reqs[idx]
45
+ end
46
+ end
47
+
48
+ unless real_requests.empty?
49
+ reqs = real_requests.keys
50
+ reqs.zip(super(*reqs, options)).each do |req, res|
51
+ idx = real_requests[req]
52
+
53
+ if WebMock::CallbackRegistry.any_callbacks?
54
+ webmock_response = _build_webmock_response(req, res)
55
+ WebMock::CallbackRegistry.invoke_callbacks(
56
+ { lib: :httpx, real_request: true }, request_signatures[idx],
57
+ webmock_response
58
+ )
59
+ end
60
+
61
+ responses[idx] = res
62
+ end
63
+ end
64
+
65
+ responses
66
+ end
67
+
68
+ def _build_webmock_request_signature(request)
69
+ uri = WebMock::Util::URI.heuristic_parse(request.uri)
70
+ uri.path = uri.normalized_path.gsub("[^:]//", "/")
71
+
72
+ WebMock::RequestSignature.new(
73
+ request.verb,
74
+ uri.to_s,
75
+ body: request.body.each.to_a.join,
76
+ headers: request.headers.to_h
77
+ )
78
+ end
79
+
80
+ def _build_webmock_response(_request, response)
81
+ webmock_response = WebMock::Response.new
82
+ webmock_response.status = [response.status, HTTP_REASONS[response.status]]
83
+ webmock_response.body = response.body.to_s
84
+ webmock_response.headers = response.headers.to_h
85
+ webmock_response
86
+ end
87
+
88
+ def _build_from_webmock_response(request, webmock_response)
89
+ return ErrorResponse.new(request, webmock_response.exception, request.options) if webmock_response.exception
90
+
91
+ response = request.options.response_class.new(request,
92
+ webmock_response.status[0],
93
+ "2.0",
94
+ webmock_response.headers)
95
+ response << webmock_response.body.dup
96
+ response
97
+ end
98
+ end
99
+ end
100
+
101
+ class HttpxAdapter < HttpLibAdapter
102
+ adapter_for :httpx
103
+
104
+ class << self
105
+ def enable!
106
+ @original_session = ::HTTPX::Session
107
+
108
+ webmock_session = ::HTTPX.plugin(Plugin)
109
+
110
+ ::HTTPX.send(:remove_const, :Session)
111
+ ::HTTPX.send(:const_set, :Session, webmock_session.class)
112
+ end
113
+
114
+ def disable!
115
+ return unless @original_session
116
+
117
+ HTTPX.send(:remove_const, :Session)
118
+ HTTPX.send(:const_set, :Session, @original_session)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
data/lib/httpx/altsvc.rb CHANGED
@@ -37,6 +37,7 @@ module HTTPX
37
37
  end
38
38
 
39
39
  def emit(request, response)
40
+ return unless response.respond_to?(:headers)
40
41
  # Alt-Svc
41
42
  return unless response.headers.key?("alt-svc")
42
43
 
@@ -59,7 +59,7 @@ module HTTPX
59
59
  private
60
60
 
61
61
  def default_options
62
- @options || Options.new
62
+ @options || Session.default_options
63
63
  end
64
64
 
65
65
  def branch(options, &blk)
@@ -170,7 +170,7 @@ module HTTPX
170
170
  end
171
171
 
172
172
  # if the write buffer is full, we drain it
173
- return :w if @write_buffer.full?
173
+ return :w unless @write_buffer.empty?
174
174
 
175
175
  return @parser.interests if @parser
176
176
 
@@ -251,11 +251,18 @@ module HTTPX
251
251
 
252
252
  def consume
253
253
  catch(:called) do
254
+ epiped = false
254
255
  loop do
255
256
  parser.consume
256
257
 
257
- # we exit if there's no more data to process
258
- if @pending.size.zero? && @inflight.zero?
258
+ # we exit if there's no more requests to process
259
+ #
260
+ # this condition takes into account:
261
+ #
262
+ # * the number of inflight requests
263
+ # * the number of pending requests
264
+ # * whether the write buffer has bytes (i.e. for close handshake)
265
+ if @pending.size.zero? && @inflight.zero? && @write_buffer.empty?
259
266
  log(level: 3) { "NO MORE REQUESTS..." }
260
267
  return
261
268
  end
@@ -265,9 +272,17 @@ module HTTPX
265
272
  read_drained = false
266
273
  write_drained = nil
267
274
 
268
- # dread
275
+ #
276
+ # tight read loop.
277
+ #
278
+ # read as much of the socket as possible.
279
+ #
280
+ # this tight loop reads all the data it can from the socket and pipes it to
281
+ # its parser.
282
+ #
269
283
  loop do
270
284
  siz = @io.read(@window_size, @read_buffer)
285
+ log(level: 3, color: :cyan) { "IO READ: #{siz} bytes..." }
271
286
  unless siz
272
287
  ex = EOFError.new("descriptor closed")
273
288
  ex.set_backtrace(caller)
@@ -275,27 +290,53 @@ module HTTPX
275
290
  return
276
291
  end
277
292
 
293
+ # socket has been drained. mark and exit the read loop.
278
294
  if siz.zero?
279
295
  read_drained = @read_buffer.empty?
296
+ epiped = false
280
297
  break
281
298
  end
282
299
 
283
300
  parser << @read_buffer.to_s
284
301
 
302
+ # continue reading if possible.
303
+ break if interests == :w && !epiped
304
+
305
+ # exit the read loop if connection is preparing to be closed
285
306
  break if @state == :closing || @state == :closed
286
307
 
287
- # for HTTP/2, we just want to write goaway frame
288
- end unless @state == :closing
308
+ # exit #consume altogether if all outstanding requests have been dealt with
309
+ return if @pending.size.zero? && @inflight.zero?
310
+ end unless (interests == :w || @state == :closing) && !epiped
289
311
 
290
- # dwrite
312
+ #
313
+ # tight write loop.
314
+ #
315
+ # flush as many bytes as the sockets allow.
316
+ #
291
317
  loop do
318
+ # buffer has been drainned, mark and exit the write loop.
292
319
  if @write_buffer.empty?
293
320
  # we only mark as drained on the first loop
294
321
  write_drained = write_drained.nil? && @inflight.positive?
322
+
295
323
  break
296
324
  end
297
325
 
298
- siz = @io.write(@write_buffer)
326
+ begin
327
+ siz = @io.write(@write_buffer)
328
+ rescue Errno::EPIPE
329
+ # this can happen if we still have bytes in the buffer to send to the server, but
330
+ # the server wants to respond immediately with some message, or an error. An example is
331
+ # when one's uploading a big file to an unintended endpoint, and the server stops the
332
+ # consumption, and responds immediately with an authorization of even method not allowed error.
333
+ # at this point, we have to let the connection switch to read-mode.
334
+ log(level: 2) { "pipe broken, could not flush buffer..." }
335
+ epiped = true
336
+ read_drained = false
337
+ break
338
+ end
339
+ log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
299
340
  unless siz
300
341
  ex = EOFError.new("descriptor closed")
301
342
  ex.set_backtrace(caller)
@@ -303,21 +344,28 @@ module HTTPX
303
344
  return
304
345
  end
305
346
 
347
+ # socket closed for writing. mark and exit the write loop.
306
348
  if siz.zero?
307
349
  write_drained = !@write_buffer.empty?
308
350
  break
309
351
  end
310
352
 
311
- break if @state == :closing || @state == :closed
353
+ # exit write loop if marked to consume from peer, or is closing.
354
+ break if interests == :r || @state == :closing || @state == :closed
312
355
 
313
356
  write_drained = false
314
- end
357
+ end unless interests == :r
315
358
 
316
359
  # return if socket is drained
317
- if read_drained && write_drained
318
- log(level: 3) { "WAITING FOR EVENTS..." }
319
- return
320
- end
360
+ next unless (interests != :r || read_drained) &&
361
+ (interests != :w || write_drained)
362
+
363
+ # gotta go back to the event loop. It happens when:
364
+ #
365
+ # * the socket is drained of bytes or it's not the interest of the conn to read;
366
+ # * theres nothing more to write, or it's not in the interest of the conn to write;
367
+ log(level: 3) { "(#{interests}): WAITING FOR EVENTS..." }
368
+ return
321
369
  end
322
370
  end
323
371
  end
@@ -444,7 +492,7 @@ module HTTPX
444
492
  throw(:jump_tick)
445
493
  rescue Errno::ECONNREFUSED,
446
494
  Errno::EADDRNOTAVAIL,
447
- OpenSSL::SSL::SSLError => e
495
+ TLSError => e
448
496
  # connect errors, exit gracefully
449
497
  handle_error(e)
450
498
  @state = :closed
@@ -69,14 +69,16 @@ module HTTPX
69
69
 
70
70
  return if @requests.include?(request)
71
71
 
72
+ request.once(:headers, &method(:set_protocol_headers))
72
73
  @requests << request
73
74
  @pipelining = true if @requests.size > 1
74
75
  end
75
76
 
76
77
  def consume
77
- requests_limit = [@max_concurrent_requests, @max_requests, @requests.size].min
78
+ requests_limit = [@max_requests, @requests.size].min
79
+ concurrent_requests_limit = [@max_concurrent_requests, requests_limit].min
78
80
  @requests.each_with_index do |request, idx|
79
- break if idx >= requests_limit
81
+ break if idx >= concurrent_requests_limit
80
82
  next if request.state == :done
81
83
 
82
84
  request.headers["connection"] ||= request.options.persistent || idx < requests_limit - 1 ? "keep-alive" : "close"
@@ -115,7 +117,7 @@ module HTTPX
115
117
  response = @request.response
116
118
  log(level: 2) { "trailer headers received" }
117
119
 
118
- log(color: :yellow) { h.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
120
+ log(color: :yellow) { h.each.map { |f, v| "-> HEADER: #{f}: #{v.join(", ")}" }.join("\n") }
119
121
  response.merge_headers(h)
120
122
  end
121
123
 
@@ -161,6 +163,16 @@ module HTTPX
161
163
  end
162
164
 
163
165
  def handle_error(ex)
166
+ if (ex.is_a?(EOFError) || ex.is_a?(TimeoutError)) && @request && @request.response &&
167
+ !@request.response.headers.key?("content-length") &&
168
+ !@request.response.headers.key?("transfer-encoding")
169
+ # if the response does not contain a content-length header, the server closing the
170
+ # connnection is the indicator of response consumed.
171
+ # https://greenbytes.de/tech/webdav/rfc2616.html#rfc.section.4.4
172
+ catch(:called) { on_complete }
173
+ return
174
+ end
175
+
164
176
  if @pipelining
165
177
  disable
166
178
  else
@@ -240,7 +252,7 @@ module HTTPX
240
252
  @pipelining = false
241
253
  end
242
254
 
243
- def set_request_headers(request)
255
+ def set_protocol_headers(request)
244
256
  request.headers["host"] ||= request.authority
245
257
  request.headers["connection"] ||= request.options.persistent ? "keep-alive" : "close"
246
258
  if !request.headers.key?("content-length") &&
@@ -254,7 +266,6 @@ module HTTPX
254
266
  end
255
267
 
256
268
  def handle(request)
257
- set_request_headers(request)
258
269
  catch(:buffer_full) do
259
270
  request.transition(:headers)
260
271
  join_headers(request) if request.state == :headers
@@ -42,11 +42,15 @@ module HTTPX
42
42
  return @buffer.empty? ? :r : :rw
43
43
  end
44
44
 
45
- return :w unless @pending.empty?
45
+ return :w if !@pending.empty? && can_buffer_more_requests?
46
46
 
47
47
  return :w if @streams.each_key.any? { |r| r.interests == :w }
48
48
 
49
- return :r if @buffer.empty?
49
+ if @buffer.empty?
50
+ return if @streams.empty? && @pings.empty?
51
+
52
+ return :r
53
+ end
50
54
 
51
55
  :rw
52
56
  end
@@ -70,10 +74,14 @@ module HTTPX
70
74
  @connection << data
71
75
  end
72
76
 
77
+ def can_buffer_more_requests?
78
+ @handshake_completed &&
79
+ @streams.size < @max_concurrent_requests &&
80
+ @streams.size < @max_requests
81
+ end
82
+
73
83
  def send(request)
74
- if !@handshake_completed ||
75
- @streams.size >= @max_concurrent_requests ||
76
- @streams.size >= @max_requests
84
+ unless can_buffer_more_requests?
77
85
  @pending << request
78
86
  return
79
87
  end
@@ -83,6 +91,7 @@ module HTTPX
83
91
  @streams[request] = stream
84
92
  @max_requests -= 1
85
93
  end
94
+ request.once(:headers, &method(:set_protocol_headers))
86
95
  handle(request, stream)
87
96
  true
88
97
  rescue HTTP2Next::Error::StreamLimitExceeded
@@ -126,8 +135,6 @@ module HTTPX
126
135
  request.path
127
136
  end
128
137
 
129
- def set_request_headers(request); end
130
-
131
138
  def handle(request, stream)
132
139
  catch(:buffer_full) do
133
140
  request.transition(:headers)
@@ -163,27 +170,27 @@ module HTTPX
163
170
  public :reset
164
171
 
165
172
  def handle_stream(stream, request)
166
- stream.on(:close, &method(:on_stream_close).curry[stream, request])
173
+ stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
167
174
  stream.on(:half_close) do
168
175
  log(level: 2) { "#{stream.id}: waiting for response..." }
169
176
  end
170
- stream.on(:altsvc, &method(:on_altsvc).curry[request.origin])
171
- stream.on(:headers, &method(:on_stream_headers).curry[stream, request])
172
- stream.on(:data, &method(:on_stream_data).curry[stream, request])
177
+ stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
178
+ stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
179
+ stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
180
+ end
181
+
182
+ def set_protocol_headers(request)
183
+ request.headers[":scheme"] = request.scheme
184
+ request.headers[":method"] = request.verb.to_s.upcase
185
+ request.headers[":path"] = headline_uri(request)
186
+ request.headers[":authority"] = request.authority
173
187
  end
174
188
 
175
189
  def join_headers(stream, request)
176
- set_request_headers(request)
177
- headers = {}
178
- headers[":scheme"] = request.scheme
179
- headers[":method"] = request.verb.to_s.upcase
180
- headers[":path"] = headline_uri(request)
181
- headers[":authority"] = request.authority
182
- headers = headers.merge(request.headers)
183
190
  log(level: 1, color: :yellow) do
184
- headers.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
191
+ request.headers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
185
192
  end
186
- stream.headers(headers, end_stream: request.empty?)
193
+ stream.headers(request.headers.each, end_stream: request.empty?)
187
194
  end
188
195
 
189
196
  def join_body(stream, request)
@@ -227,10 +234,15 @@ module HTTPX
227
234
  end
228
235
 
229
236
  def on_stream_close(stream, request, error)
237
+ log(level: 2) { "#{stream.id}: closing stream" }
238
+ @drains.delete(request)
239
+ @streams.delete(request)
240
+
230
241
  if error && error != :no_error
231
242
  ex = Error.new(stream.id, error)
232
243
  ex.set_backtrace(caller)
233
- emit(:error, request, ex)
244
+ response = ErrorResponse.new(request, ex, request.options)
245
+ emit(:response, request, response)
234
246
  else
235
247
  response = request.response
236
248
  if response.status == 421
@@ -241,9 +253,6 @@ module HTTPX
241
253
  emit(:response, request, response)
242
254
  end
243
255
  end
244
- log(level: 2) { "#{stream.id}: closing stream" }
245
-
246
- @streams.delete(request)
247
256
  send(@pending.shift) unless @pending.empty?
248
257
  return unless @streams.empty? && exhausted?
249
258
 
@@ -328,11 +337,9 @@ module HTTPX
328
337
  end
329
338
 
330
339
  def method_missing(meth, *args, &blk)
331
- if @connection.respond_to?(meth)
332
- @connection.__send__(meth, *args, &blk)
333
- else
334
- super
335
- end
340
+ return super unless @connection.respond_to?(meth)
341
+
342
+ @connection.__send__(meth, *args, &blk)
336
343
  end
337
344
  end
338
345
  Connection.register "h2", Connection::HTTP2