httpx 1.4.0 → 1.4.4

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -2
  3. data/doc/release_notes/1_4_1.md +19 -0
  4. data/doc/release_notes/1_4_2.md +20 -0
  5. data/doc/release_notes/1_4_3.md +11 -0
  6. data/doc/release_notes/1_4_4.md +14 -0
  7. data/lib/httpx/adapters/datadog.rb +55 -83
  8. data/lib/httpx/adapters/faraday.rb +1 -1
  9. data/lib/httpx/adapters/webmock.rb +11 -1
  10. data/lib/httpx/callbacks.rb +2 -2
  11. data/lib/httpx/connection/http2.rb +33 -18
  12. data/lib/httpx/connection.rb +115 -55
  13. data/lib/httpx/errors.rb +3 -4
  14. data/lib/httpx/io/ssl.rb +6 -3
  15. data/lib/httpx/loggable.rb +13 -6
  16. data/lib/httpx/plugins/callbacks.rb +1 -0
  17. data/lib/httpx/plugins/circuit_breaker.rb +1 -0
  18. data/lib/httpx/plugins/expect.rb +1 -1
  19. data/lib/httpx/plugins/grpc/grpc_encoding.rb +2 -0
  20. data/lib/httpx/plugins/internal_telemetry.rb +21 -1
  21. data/lib/httpx/plugins/retries.rb +2 -2
  22. data/lib/httpx/plugins/stream.rb +42 -18
  23. data/lib/httpx/request/body.rb +9 -14
  24. data/lib/httpx/request.rb +37 -3
  25. data/lib/httpx/resolver/https.rb +4 -2
  26. data/lib/httpx/resolver/native.rb +111 -55
  27. data/lib/httpx/resolver/resolver.rb +18 -11
  28. data/lib/httpx/resolver/system.rb +3 -5
  29. data/lib/httpx/response.rb +9 -4
  30. data/lib/httpx/selector.rb +33 -23
  31. data/lib/httpx/session.rb +20 -49
  32. data/lib/httpx/timers.rb +16 -1
  33. data/lib/httpx/transcoder/body.rb +15 -31
  34. data/lib/httpx/transcoder/multipart/encoder.rb +2 -1
  35. data/lib/httpx/transcoder/multipart/part.rb +1 -1
  36. data/lib/httpx/version.rb +1 -1
  37. data/lib/httpx.rb +1 -1
  38. data/sig/callbacks.rbs +2 -2
  39. data/sig/connection/http2.rbs +4 -0
  40. data/sig/connection.rbs +19 -5
  41. data/sig/errors.rbs +3 -3
  42. data/sig/loggable.rbs +2 -2
  43. data/sig/plugins/stream.rbs +3 -0
  44. data/sig/pool.rbs +2 -0
  45. data/sig/request/body.rbs +0 -8
  46. data/sig/request.rbs +12 -0
  47. data/sig/resolver/native.rbs +6 -1
  48. data/sig/response.rbs +8 -3
  49. data/sig/selector.rbs +1 -0
  50. data/sig/session.rbs +2 -0
  51. data/sig/timers.rbs +15 -4
  52. data/sig/transcoder/body.rbs +1 -3
  53. data/sig/transcoder/json.rbs +1 -1
  54. data/sig/transcoder/multipart.rbs +1 -1
  55. data/sig/transcoder/utils/body_reader.rbs +1 -1
  56. data/sig/transcoder/utils/deflater.rbs +1 -2
  57. metadata +11 -9
  58. data/lib/httpx/session2.rb +0 -23
  59. data/lib/httpx/transcoder/utils/inflater.rb +0 -21
  60. data/sig/transcoder/utils/inflater.rbs +0 -12
data/lib/httpx/request.rb CHANGED
@@ -11,6 +11,8 @@ module HTTPX
11
11
  include Callbacks
12
12
  using URIExtensions
13
13
 
14
+ ALLOWED_URI_SCHEMES = %w[https http].freeze
15
+
14
16
  # default value used for "user-agent" header, when not overridden.
15
17
  USER_AGENT = "httpx.rb/#{VERSION}".freeze # rubocop:disable Style/RedundantFreeze
16
18
 
@@ -43,9 +45,14 @@ module HTTPX
43
45
 
44
46
  attr_writer :persistent
45
47
 
48
+ attr_reader :active_timeouts
49
+
46
50
  # will be +true+ when request body has been completely flushed.
47
51
  def_delegator :@body, :empty?
48
52
 
53
+ # closes the body
54
+ def_delegator :@body, :close
55
+
49
56
  # initializes the instance with the given +verb+ (an upppercase String, ex. 'GEt'),
50
57
  # an absolute or relative +uri+ (either as String or URI::HTTP object), the
51
58
  # request +options+ (instance of HTTPX::Options) and an optional Hash of +params+.
@@ -92,23 +99,37 @@ module HTTPX
92
99
  @uri = origin.merge("#{base_path}#{@uri}")
93
100
  end
94
101
 
102
+ raise UnsupportedSchemeError, "#{@uri}: #{@uri.scheme}: unsupported URI scheme" unless ALLOWED_URI_SCHEMES.include?(@uri.scheme)
103
+
95
104
  @state = :idle
96
105
  @response = nil
97
106
  @peer_address = nil
107
+ @ping = false
98
108
  @persistent = @options.persistent
109
+ @active_timeouts = []
110
+ end
111
+
112
+ # whether request has been buffered with a ping
113
+ def ping?
114
+ @ping
115
+ end
116
+
117
+ # marks the request as having been buffered with a ping
118
+ def ping!
119
+ @ping = true
99
120
  end
100
121
 
101
- # the read timeout defined for this requet.
122
+ # the read timeout defined for this request.
102
123
  def read_timeout
103
124
  @options.timeout[:read_timeout]
104
125
  end
105
126
 
106
- # the write timeout defined for this requet.
127
+ # the write timeout defined for this request.
107
128
  def write_timeout
108
129
  @options.timeout[:write_timeout]
109
130
  end
110
131
 
111
- # the request timeout defined for this requet.
132
+ # the request timeout defined for this request.
112
133
  def request_timeout
113
134
  @options.timeout[:request_timeout]
114
135
  end
@@ -239,10 +260,13 @@ module HTTPX
239
260
  case nextstate
240
261
  when :idle
241
262
  @body.rewind
263
+ @ping = false
242
264
  @response = nil
243
265
  @drainer = nil
266
+ @active_timeouts.clear
244
267
  when :headers
245
268
  return unless @state == :idle
269
+
246
270
  when :body
247
271
  return unless @state == :headers ||
248
272
  @state == :expect
@@ -263,6 +287,7 @@ module HTTPX
263
287
  return unless @state == :body
264
288
  when :done
265
289
  return if @state == :expect
290
+
266
291
  end
267
292
  @state = nextstate
268
293
  emit(@state, self)
@@ -273,6 +298,15 @@ module HTTPX
273
298
  def expects?
274
299
  @headers["expect"] == "100-continue" && @informational_status == 100 && !@response
275
300
  end
301
+
302
+ def set_timeout_callback(event, &callback)
303
+ clb = once(event, &callback)
304
+
305
+ # reset timeout callbacks when requests get rerouted to a different connection
306
+ once(:idle) do
307
+ callbacks(event).delete(clb)
308
+ end
309
+ end
276
310
  end
277
311
  end
278
312
 
@@ -82,7 +82,9 @@ module HTTPX
82
82
 
83
83
  if hostname.nil?
84
84
  hostname = connection.peer.host
85
- log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
85
+ log do
86
+ "resolver #{FAMILY_TYPES[@record_type]}: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
87
+ end if connection.peer.non_ascii_hostname
86
88
 
87
89
  hostname = @resolver.generate_candidates(hostname).each do |name|
88
90
  @queries[name.to_s] = connection
@@ -90,7 +92,7 @@ module HTTPX
90
92
  else
91
93
  @queries[hostname] = connection
92
94
  end
93
- log { "resolver: query #{FAMILY_TYPES[RECORD_TYPES[@family]]} for #{hostname}" }
95
+ log { "resolver #{FAMILY_TYPES[@record_type]}: query for #{hostname}" }
94
96
 
95
97
  begin
96
98
  request = build_request(hostname)
@@ -35,6 +35,7 @@ module HTTPX
35
35
  @_timeouts = Array(@resolver_options[:timeouts])
36
36
  @timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
37
37
  @connections = []
38
+ @name = nil
38
39
  @queries = {}
39
40
  @read_buffer = "".b
40
41
  @write_buffer = Buffer.new(@resolver_options[:packet_size])
@@ -58,19 +59,6 @@ module HTTPX
58
59
  when :open
59
60
  consume
60
61
  end
61
- nil
62
- rescue Errno::EHOSTUNREACH => e
63
- @ns_index += 1
64
- nameserver = @nameserver
65
- if nameserver && @ns_index < nameserver.size
66
- log { "resolver: failed resolving on nameserver #{@nameserver[@ns_index - 1]} (#{e.message})" }
67
- transition(:idle)
68
- @timeouts.clear
69
- else
70
- handle_error(e)
71
- end
72
- rescue NativeResolveError => e
73
- handle_error(e)
74
62
  end
75
63
 
76
64
  def interests
@@ -105,9 +93,7 @@ module HTTPX
105
93
  @timeouts.values_at(*hosts).reject(&:empty?).map(&:first).min
106
94
  end
107
95
 
108
- def handle_socket_timeout(interval)
109
- do_retry(interval)
110
- end
96
+ def handle_socket_timeout(interval); end
111
97
 
112
98
  private
113
99
 
@@ -120,54 +106,94 @@ module HTTPX
120
106
  end
121
107
 
122
108
  def consume
123
- dread if calculate_interests == :r
124
- do_retry
125
- dwrite if calculate_interests == :w
109
+ loop do
110
+ dread if calculate_interests == :r
111
+
112
+ break unless calculate_interests == :w
113
+
114
+ # do_retry
115
+ dwrite
116
+
117
+ break unless calculate_interests == :r
118
+ end
119
+ rescue Errno::EHOSTUNREACH => e
120
+ @ns_index += 1
121
+ nameserver = @nameserver
122
+ if nameserver && @ns_index < nameserver.size
123
+ log do
124
+ "resolver #{FAMILY_TYPES[@record_type]}: " \
125
+ "failed resolving on nameserver #{@nameserver[@ns_index - 1]} (#{e.message})"
126
+ end
127
+ transition(:idle)
128
+ @timeouts.clear
129
+ retry
130
+ else
131
+ handle_error(e)
132
+ emit(:close, self)
133
+ end
134
+ rescue NativeResolveError => e
135
+ handle_error(e)
136
+ close_or_resolve
137
+ retry unless closed?
126
138
  end
127
139
 
128
- def do_retry(loop_time = nil)
129
- return if @queries.empty? || !@start_timeout
140
+ def schedule_retry
141
+ h = @name
130
142
 
131
- loop_time ||= Utils.elapsed_time(@start_timeout)
143
+ return unless h
132
144
 
133
- query = @queries.first
145
+ connection = @queries[h]
134
146
 
135
- return unless query
147
+ timeouts = @timeouts[h]
148
+ timeout = timeouts.shift
136
149
 
137
- h, connection = query
138
- host = connection.peer.host
139
- timeout = (@timeouts[host][0] -= loop_time)
150
+ @timer = @current_selector.after(timeout) do
151
+ next unless @connections.include?(connection)
140
152
 
141
- return unless timeout <= 0
153
+ do_retry(h, connection, timeout)
154
+ end
155
+ end
142
156
 
143
- @timeouts[host].shift
157
+ def do_retry(h, connection, interval)
158
+ timeouts = @timeouts[h]
144
159
 
145
- if !@timeouts[host].empty?
146
- log { "resolver: timeout after #{timeout}s, retry(#{@timeouts[host].first}) #{host}..." }
160
+ if !timeouts.empty?
161
+ log do
162
+ "resolver #{FAMILY_TYPES[@record_type]}: timeout after #{interval}s, retry (with #{timeouts.first}s) #{h}..."
163
+ end
147
164
  # must downgrade to tcp AND retry on same host as last
148
165
  downgrade_socket
149
166
  resolve(connection, h)
150
167
  elsif @ns_index + 1 < @nameserver.size
151
168
  # try on the next nameserver
152
169
  @ns_index += 1
153
- log { "resolver: failed resolving #{host} on nameserver #{@nameserver[@ns_index - 1]} (timeout error)" }
170
+ log do
171
+ "resolver #{FAMILY_TYPES[@record_type]}: failed resolving #{h} on nameserver #{@nameserver[@ns_index - 1]} (timeout error)"
172
+ end
154
173
  transition(:idle)
155
174
  @timeouts.clear
156
175
  resolve(connection, h)
157
176
  else
158
177
 
159
- @timeouts.delete(host)
178
+ @timeouts.delete(h)
160
179
  reset_hostname(h, reset_candidates: false)
161
180
 
162
- return unless @queries.empty?
181
+ unless @queries.empty?
182
+ resolve(connection)
183
+ return
184
+ end
163
185
 
164
186
  @connections.delete(connection)
187
+
188
+ host = connection.peer.host
189
+
165
190
  # This loop_time passed to the exception is bogus. Ideally we would pass the total
166
191
  # resolve timeout, including from the previous retries.
167
- ex = ResolveTimeoutError.new(loop_time, "Timed out while resolving #{connection.peer.host}")
192
+ ex = ResolveTimeoutError.new(interval, "Timed out while resolving #{host}")
168
193
  ex.set_backtrace(ex ? ex.backtrace : caller)
169
194
  emit_resolve_error(connection, host, ex)
170
- emit(:close, self)
195
+
196
+ close_or_resolve
171
197
  end
172
198
  end
173
199
 
@@ -216,7 +242,7 @@ module HTTPX
216
242
  parse(@read_buffer)
217
243
  end
218
244
 
219
- return if @state == :closed
245
+ return if @state == :closed || !@write_buffer.empty?
220
246
  end
221
247
  end
222
248
 
@@ -234,11 +260,15 @@ module HTTPX
234
260
 
235
261
  return unless siz.positive?
236
262
 
263
+ schedule_retry if @write_buffer.empty?
264
+
237
265
  return if @state == :closed
238
266
  end
239
267
  end
240
268
 
241
269
  def parse(buffer)
270
+ @timer.cancel
271
+
242
272
  code, result = Resolver.decode_dns_answer(buffer)
243
273
 
244
274
  case code
@@ -249,15 +279,17 @@ module HTTPX
249
279
  hostname, connection = @queries.first
250
280
  reset_hostname(hostname, reset_candidates: false)
251
281
 
252
- unless @queries.value?(connection)
282
+ other_candidate, _ = @queries.find { |_, conn| conn == connection }
283
+
284
+ if other_candidate
285
+ resolve(connection, other_candidate)
286
+ else
253
287
  @connections.delete(connection)
254
288
  ex = NativeResolveError.new(connection, connection.peer.host, "name or service not known")
255
289
  ex.set_backtrace(ex ? ex.backtrace : caller)
256
290
  emit_resolve_error(connection, connection.peer.host, ex)
257
- emit(:close, self)
291
+ close_or_resolve
258
292
  end
259
-
260
- resolve
261
293
  when :message_truncated
262
294
  # TODO: what to do if it's already tcp??
263
295
  return if @socket_type == :tcp
@@ -312,8 +344,10 @@ module HTTPX
312
344
  connection = @queries.delete(name)
313
345
  end
314
346
 
315
- if address.key?("alias") # CNAME
316
- hostname_alias = address["alias"]
347
+ alias_addresses, addresses = addresses.partition { |addr| addr.key?("alias") }
348
+
349
+ if addresses.empty? && !alias_addresses.empty? # CNAME
350
+ hostname_alias = alias_addresses.first["alias"]
317
351
  # clean up intermediate queries
318
352
  @timeouts.delete(name) unless connection.peer.host == name
319
353
 
@@ -326,7 +360,7 @@ module HTTPX
326
360
  transition(:idle)
327
361
  transition(:open)
328
362
  end
329
- log { "resolver: ALIAS #{hostname_alias} for #{name}" }
363
+ log { "resolver #{FAMILY_TYPES[@record_type]}: ALIAS #{hostname_alias} for #{name}" }
330
364
  resolve(connection, hostname_alias)
331
365
  return
332
366
  end
@@ -338,12 +372,14 @@ module HTTPX
338
372
  catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |addr| addr["data"] }) }
339
373
  end
340
374
  end
341
- return emit(:close, self) if @connections.empty?
342
-
343
- resolve
375
+ close_or_resolve
344
376
  end
345
377
 
346
- def resolve(connection = @connections.first, hostname = nil)
378
+ def resolve(connection = nil, hostname = nil)
379
+ @connections.shift until @connections.empty? || @connections.first.state != :closed
380
+
381
+ connection ||= @connections.find { |c| !@queries.value?(c) }
382
+
347
383
  raise Error, "no URI to resolve" unless connection
348
384
 
349
385
  return unless @write_buffer.empty?
@@ -352,7 +388,10 @@ module HTTPX
352
388
 
353
389
  if hostname.nil?
354
390
  hostname = connection.peer.host
355
- log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
391
+ log do
392
+ "resolver #{FAMILY_TYPES[@record_type]}: " \
393
+ "resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
394
+ end if connection.peer.non_ascii_hostname
356
395
 
357
396
  hostname = generate_candidates(hostname).each do |name|
358
397
  @queries[name] = connection
@@ -360,14 +399,17 @@ module HTTPX
360
399
  else
361
400
  @queries[hostname] = connection
362
401
  end
363
- log { "resolver: query #{@record_type.name.split("::").last} for #{hostname}" }
402
+
403
+ @name = hostname
404
+
405
+ log { "resolver #{FAMILY_TYPES[@record_type]}: query for #{hostname}" }
364
406
  begin
365
407
  @write_buffer << encode_dns_query(hostname)
366
408
  rescue Resolv::DNS::EncodeError => e
367
409
  reset_hostname(hostname, connection: connection)
368
410
  @connections.delete(connection)
369
411
  emit_resolve_error(connection, hostname, e)
370
- emit(:close, self) if @connections.empty?
412
+ close_or_resolve
371
413
  end
372
414
  end
373
415
 
@@ -397,10 +439,10 @@ module HTTPX
397
439
 
398
440
  case @socket_type
399
441
  when :udp
400
- log { "resolver: server: udp://#{ip}:#{port}..." }
442
+ log { "resolver #{FAMILY_TYPES[@record_type]}: server: udp://#{ip}:#{port}..." }
401
443
  UDP.new(ip, port, @options)
402
444
  when :tcp
403
- log { "resolver: server: tcp://#{ip}:#{port}..." }
445
+ log { "resolver #{FAMILY_TYPES[@record_type]}: server: tcp://#{ip}:#{port}..." }
404
446
  origin = URI("tcp://#{ip}:#{port}")
405
447
  TCP.new(origin, [ip], @options)
406
448
  end
@@ -448,6 +490,7 @@ module HTTPX
448
490
  # these errors may happen during TCP handshake
449
491
  # treat them as resolve errors.
450
492
  handle_error(e)
493
+ emit(:close, self)
451
494
  end
452
495
 
453
496
  def handle_error(error)
@@ -462,13 +505,15 @@ module HTTPX
462
505
  @connections.delete(connection)
463
506
  emit_resolve_error(connection, host, error)
464
507
  end
508
+
509
+ while (connection = @connections.shift)
510
+ emit_resolve_error(connection, host, error)
511
+ end
465
512
  end
466
- emit(:close, self) if @connections.empty?
467
513
  end
468
514
 
469
515
  def reset_hostname(hostname, connection: @queries.delete(hostname), reset_candidates: true)
470
516
  @timeouts.delete(hostname)
471
- @timeouts.delete(hostname)
472
517
 
473
518
  return unless connection && reset_candidates
474
519
 
@@ -478,5 +523,16 @@ module HTTPX
478
523
  # reset timeouts
479
524
  @timeouts.delete_if { |h, _| candidates.include?(h) }
480
525
  end
526
+
527
+ def close_or_resolve
528
+ # drop already closed connections
529
+ @connections.shift until @connections.empty? || @connections.first.state != :closed
530
+
531
+ if (@connections - @queries.values).empty?
532
+ emit(:close, self)
533
+ else
534
+ resolve
535
+ end
536
+ end
481
537
  end
482
538
  end
@@ -72,17 +72,22 @@ module HTTPX
72
72
  # double emission check, but allow early resolution to work
73
73
  return if !early_resolve && connection.addresses && !addresses.intersect?(connection.addresses)
74
74
 
75
- log { "resolver: answer #{FAMILY_TYPES[RECORD_TYPES[family]]} #{connection.peer.host}: #{addresses.inspect}" }
76
- if @current_selector && # if triggered by early resolve, session may not be here yet
77
- !connection.io &&
78
- connection.options.ip_families.size > 1 &&
79
- family == Socket::AF_INET &&
80
- addresses.first.to_s != connection.peer.host.to_s
81
- log { "resolver: A response, applying resolution delay..." }
75
+ log do
76
+ "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \
77
+ "answer #{connection.peer.host}: #{addresses.inspect} (early resolve: #{early_resolve})"
78
+ end
79
+
80
+ if !early_resolve && # do not apply resolution delay for non-dns name resolution
81
+ @current_selector && # just in case...
82
+ family == Socket::AF_INET && # resolution delay only applies to IPv4
83
+ !connection.io && # connection already has addresses and initiated/ended handshake
84
+ connection.options.ip_families.size > 1 && # no need to delay if not supporting dual stack IP
85
+ addresses.first.to_s != connection.peer.host.to_s # connection URL host is already the IP (early resolve included perhaps?)
86
+ log { "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: applying resolution delay..." }
87
+
82
88
  @current_selector.after(0.05) do
83
- unless connection.state == :closed ||
84
- # double emission check
85
- (connection.addresses && addresses.intersect?(connection.addresses))
89
+ # double emission check
90
+ unless connection.addresses && addresses.intersect?(connection.addresses)
86
91
  emit_resolved_connection(connection, addresses, early_resolve)
87
92
  end
88
93
  end
@@ -97,6 +102,8 @@ module HTTPX
97
102
  begin
98
103
  connection.addresses = addresses
99
104
 
105
+ return if connection.state == :closed
106
+
100
107
  emit(:resolve, connection)
101
108
  rescue StandardError => e
102
109
  if early_resolve
@@ -146,7 +153,7 @@ module HTTPX
146
153
  end
147
154
 
148
155
  def emit_connection_error(connection, error)
149
- return connection.emit(:connect_error, error) if connection.connecting? && connection.callbacks_for?(:connect_error)
156
+ return connection.handle_connect_error(error) if connection.connecting?
150
157
 
151
158
  connection.emit(:error, error)
152
159
  end
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
3
  require "resolv"
5
4
 
6
5
  module HTTPX
7
6
  class Resolver::System < Resolver::Resolver
8
7
  using URIExtensions
9
- extend Forwardable
10
8
 
11
9
  RESOLV_ERRORS = [Resolv::ResolvError,
12
10
  Resolv::DNS::Requester::RequestError,
@@ -24,8 +22,6 @@ module HTTPX
24
22
 
25
23
  attr_reader :state
26
24
 
27
- def_delegator :@connections, :empty?
28
-
29
25
  def initialize(options)
30
26
  super(nil, options)
31
27
  @resolver_options = @options.resolver_options
@@ -162,7 +158,9 @@ module HTTPX
162
158
 
163
159
  hostname = connection.peer.host
164
160
  scheme = connection.origin.scheme
165
- log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
161
+ log do
162
+ "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
163
+ end if connection.peer.non_ascii_hostname
166
164
 
167
165
  transition(:open)
168
166
 
@@ -52,9 +52,6 @@ module HTTPX
52
52
  # copies the response body to a different location.
53
53
  def_delegator :@body, :copy_to
54
54
 
55
- # closes the body.
56
- def_delegator :@body, :close
57
-
58
55
  # the corresponding request uri.
59
56
  def_delegator :@request, :uri
60
57
 
@@ -74,6 +71,12 @@ module HTTPX
74
71
  @content_type = nil
75
72
  end
76
73
 
74
+ # closes the respective +@request+ and +@body+.
75
+ def close
76
+ @request.close
77
+ @body.close
78
+ end
79
+
77
80
  # merges headers defined in +h+ into the response headers.
78
81
  def merge_headers(h)
79
82
  @headers = @headers.merge(h)
@@ -264,7 +267,7 @@ module HTTPX
264
267
 
265
268
  # closes the error resources.
266
269
  def close
267
- @response.close if @response && @response.respond_to?(:close)
270
+ @response.close if @response
268
271
  end
269
272
 
270
273
  # always true for error responses.
@@ -279,6 +282,8 @@ module HTTPX
279
282
 
280
283
  # buffers lost chunks to error response
281
284
  def <<(data)
285
+ return unless @response
286
+
282
287
  @response << data
283
288
  end
284
289
  end
@@ -19,6 +19,7 @@ module HTTPX
19
19
  def initialize
20
20
  @timers = Timers.new
21
21
  @selectables = []
22
+ @is_timer_interval = false
22
23
  end
23
24
 
24
25
  def each(&blk)
@@ -43,7 +44,11 @@ module HTTPX
43
44
  rescue StandardError => e
44
45
  emit_error(e)
45
46
  rescue Exception # rubocop:disable Lint/RescueException
46
- each_connection(&:force_reset)
47
+ each_connection do |conn|
48
+ conn.force_reset
49
+ conn.disconnect
50
+ end
51
+
47
52
  raise
48
53
  end
49
54
 
@@ -92,10 +97,6 @@ module HTTPX
92
97
  end
93
98
  end
94
99
 
95
- def empty?
96
- @selectables.empty?
97
- end
98
-
99
100
  # deregisters +io+ from selectables.
100
101
  def deregister(io)
101
102
  @selectables.delete(io)
@@ -129,24 +130,22 @@ module HTTPX
129
130
  # first, we group IOs based on interest type. On call to #interests however,
130
131
  # things might already happen, and new IOs might be registered, so we might
131
132
  # have to start all over again. We do this until we group all selectables
132
- begin
133
- @selectables.delete_if do |io|
134
- interests = io.interests
133
+ @selectables.delete_if do |io|
134
+ interests = io.interests
135
135
 
136
- (r ||= []) << io if READABLE.include?(interests)
137
- (w ||= []) << io if WRITABLE.include?(interests)
136
+ (r ||= []) << io if READABLE.include?(interests)
137
+ (w ||= []) << io if WRITABLE.include?(interests)
138
138
 
139
- io.state == :closed
140
- end
139
+ io.state == :closed
140
+ end
141
141
 
142
- # TODO: what to do if there are no selectables?
142
+ # TODO: what to do if there are no selectables?
143
143
 
144
- readers, writers = IO.select(r, w, nil, interval)
144
+ readers, writers = IO.select(r, w, nil, interval)
145
145
 
146
- if readers.nil? && writers.nil? && interval
147
- [*r, *w].each { |io| io.handle_socket_timeout(interval) }
148
- return
149
- end
146
+ if readers.nil? && writers.nil? && interval
147
+ [*r, *w].each { |io| io.handle_socket_timeout(interval) }
148
+ return
150
149
  end
151
150
 
152
151
  if writers
@@ -178,7 +177,7 @@ module HTTPX
178
177
  end
179
178
 
180
179
  unless result || interval.nil?
181
- io.handle_socket_timeout(interval)
180
+ io.handle_socket_timeout(interval) unless @is_timer_interval
182
181
  return
183
182
  end
184
183
  # raise TimeoutError.new(interval, "timed out while waiting on select")
@@ -190,10 +189,21 @@ module HTTPX
190
189
  end
191
190
 
192
191
  def next_timeout
193
- [
194
- @timers.wait_interval,
195
- @selectables.filter_map(&:timeout).min,
196
- ].compact.min
192
+ @is_timer_interval = false
193
+
194
+ timer_interval = @timers.wait_interval
195
+
196
+ connection_interval = @selectables.filter_map(&:timeout).min
197
+
198
+ return connection_interval unless timer_interval
199
+
200
+ if connection_interval.nil? || timer_interval <= connection_interval
201
+ @is_timer_interval = true
202
+
203
+ return timer_interval
204
+ end
205
+
206
+ connection_interval
197
207
  end
198
208
 
199
209
  def emit_error(e)