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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5faeb9a08adff992ddd2661b4ccd5176cb2c49d17dbb6a0ea593e1a00c10f35b
4
- data.tar.gz: 04f81429bbe03762c641531f0886c9b133f8fa04f8607943534ae36736f705e7
3
+ metadata.gz: e272ec707fd07e29993d6f5eff50f84f40ad4da61503552f1de2698a8b56b72b
4
+ data.tar.gz: 10ebe441bd62cc1fec49c3caedb7a564dd998d2dd49d0740b3224f583f23e506
5
5
  SHA512:
6
- metadata.gz: e750874b2e206ff8604e804fa526847cc40c5511608d818c3a4a7c3136b383f5ca7c8baffbc856bd080ca0b77becc9ebc10d88e7743b5a757c7dd00b49a7e57c
7
- data.tar.gz: 0e6e2f5aaf85be60d2fc044cad8ad7e215ae9a7768c11681f0ea5a5bcb264f3f7b8fb3aaf5a21bb2c94a8b03445ac26e0972e637ebc3a30d4f204238d79dbb96
6
+ metadata.gz: 860445bf67dc3bb69f9724d1dcc13f5a7c21a936420addd0923aa4d955be929afd95b87fb032f5a226d245d65b4e07654b6df431992e755341583a5f0e5c26f9
7
+ data.tar.gz: 4f11cd526c5099fa92d47fbd6b08a4366af6a01f4075f3df96f861cf7f3ce33e17a34e69f436b453e51d8afa07535b2bc62c04e1c1f260942782b0f432cc7383
@@ -0,0 +1,13 @@
1
+ # 1.8.4
2
+
3
+ ## Improvements
4
+
5
+ * send requests which were not sent, or were not processed by the server, when the server sends back a GOAWAY frame (which is deemed recoverable, like when there's no error), after forcing a reconnection (instead of bubbling up the GOAWAY error for all of them).
6
+
7
+ ## Bugfixes
8
+
9
+ * `:retries` plugin: also retry requests which failed due to a RST_STREAM frame with REFUSED_STREAM error code.
10
+ * `:retries` plugin: resend requests which were piped to a closed connection internally.
11
+ * `:proxy` plugin: fix issue related with reconnecting inactive connections shut down by the peer, when used with the `:persistent` plugin, where the CONNECT request wasn't being sent to establish the tunnel.
12
+ * fixed compatibility issue starting with the `json` gem 3.0.0, when calling `HTTPX::Response#json`.
13
+ * avoid bookkepping issue in session, where the `on_response_arrived` on a request may be called more than once (which it can happen in the `:follow_redirects` plugin, where a request may have completed due to an error, while the request is waiting to be resent due to `retry-after`).
@@ -0,0 +1,75 @@
1
+ # 1.9.0
2
+
3
+ ## Features
4
+
5
+ ### `:hedging` plugin
6
+
7
+ The `:hedging` implements a strategy called "request hedging", whereby a request is sent a request two times, over different connections, and picking up whichever arrives first.
8
+
9
+ ```ruby
10
+ http = HTTP.plugin(:hedging)
11
+ http.get("http://www.example.com")
12
+ # sent two times, only the fastest one comes back...
13
+ ```
14
+
15
+ ou can read more about it in https://gitlab.com/os85/httpx/wikis/Hedging .
16
+
17
+ ### `:proxy/http` plugin: new options `:proxy_headers` and `:enable_proxy_tunnel`
18
+
19
+ The `:proxy/http` plugin (which gets automatically loaded by the `:proxy` plugin) gains two new options.
20
+
21
+ #### `:proxy_headers`
22
+
23
+ Can be passed a separate set of HTTP headers, which are going to be sent to the proxy when establishing the the CONNECT proxy tunnel.
24
+
25
+ ```ruby
26
+ http = HTTPX.plugin(:proxy).with(proxy_headers: { "x-proxy-1" => "foo" }, headers: { "x-header-1" => "bar" }).get("https://example.com")
27
+ # -> CONNECT example.com HTTP/1.1
28
+ # -> Host: example.com
29
+ # -> X-Proxy-1: foo
30
+ # ...
31
+ # <- 200 OK
32
+ # ...
33
+ # -> GET / HTTP/1.1
34
+ # -> X-Headers-1: bar
35
+ # ...
36
+ ```
37
+
38
+ #### `:enable_proxy_tunnel`
39
+
40
+ When set to `true`, it forces the establishment of a CONNECT proxy tunnel before sending the request. Bear in mind that this will be the norm for `https://` requests, so only use this in cases where the requests are to be sent to an `http://` URL and a CONNECT proxy tunnel is required.
41
+
42
+ ## Improvements
43
+
44
+ * `:request_timeout` will also observe the connect phase, in case a request is sent to an idle connection. This means that, if you're using `:request_timeout`, you no longer need to specify `:connect_timeout`.
45
+ * The same will happen if you're using `:total_request_timeout`.
46
+ * do not require "io/wait" anymore for ruby 3.2 or higher.
47
+ * `:proxy/http` plugin: raise a `HTTPX::ConnectTimeoutError` if there was a timeout error while waiting for the CONNECT request to complete.
48
+
49
+ ## Bugfixes
50
+
51
+ * `:auth` plugin: fixed clause inverted check, which caused caused a new token to always be generated on retry.
52
+ * `:auth` plugin: take auth_header_expires_at into account when deciding to regenerate on retry (token may have been expired by the time the request will be retried).
53
+ * `:tracing` plugin: fixed request initial time calculation in order to take into account the time to wait for a PING correctly.
54
+ * `:tracing` plugin: fixed request initial time calculation for pending requests which were waiting for the connect phase and errored meanwhie.
55
+ * `:proxy/http` plugin: `:headers` will not be sent anymore to the proxy when establishing the CONNECT proxy tunnel.
56
+ * socket connect timeout bookkeeping is fixed, where in a multi request scenario, connect timeout errors shall not be raised too early if an error happens before the remainder of connections elapses.
57
+ * `:proxy` plugin: allow proxy params to be frozen, which also fixes an issue where proxies identified as unhealthy by a previous request, would still be retried on for different hostnames.
58
+ * `:callbacks` plugin: fix broken visibility of session `#close` method.
59
+ * native resolver: retry dns queries on not-imp and refused errors (treats them as transient resolver errors).
60
+ * `webmock` adapter: fix emission of `:response` callback so that this only happens after the body is fully buffered (which makes the `:callbacks` plugin `.on_response_completed` callback works the same way when and when not stubbed).
61
+ * `:addresses`: fix support for the documented variant where a hostname/ips hash is passed.
62
+ * `http2`: fix interests calculation, so that:
63
+ * a connection expects to read from connections which still have in-transit streams.
64
+ * an HTTP/2 with no available flow control window in its streams will wait for incoming WINDOW_UPDATE frames.
65
+ * `http2`: pause buffering the request body if there's no available control window in the corresponding stream or connection.
66
+ * `http2`: reset the ping timer too if a connection receives a GOAWAY frame while waiting for a PING to be ack'ed.
67
+ * wait for read or write for any operation, considering that `OpenSSL::SSL::SSLSocket` objects may return both `.wait_readable` and `:wait_writable` for reads and writes.
68
+ * when using `:max_requests`, also account for inflight requests when deciding to send it to the parser or the pending queue.
69
+ * fixed `HTTPX::Options#merge` which was not freezing the resulting options object, something which happens when enabling plugins, which made resulting sessions unusable across Ractors.
70
+ * fixed the `Alt-Svc` header parser to be able to parse values with multiple authorities.
71
+ * fixed request params handling so that they are passed to all requests invoked (instead of only the first of them).
72
+
73
+ ## Chore
74
+
75
+ * Emit warning message whenever connection is deactivated with pending requests; the message contains internal state which should help debug it.
@@ -133,10 +133,10 @@ module WebMock
133
133
  request.transition(:done)
134
134
  response.finish!
135
135
  request.response = response
136
+ response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
136
137
  request.emit_response(response)
137
138
  request_signature.headers = request.headers.to_h
138
139
 
139
- response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
140
140
  elsif WebMock.net_connect_allowed?(request_signature.uri)
141
141
  if WebMock::CallbackRegistry.any_callbacks?
142
142
  request.on(:response) do |resp|
data/lib/httpx/altsvc.rb CHANGED
@@ -10,7 +10,7 @@ module HTTPX
10
10
 
11
11
  H2_ALTSVC_SCHEMES = %w[https h2].freeze
12
12
 
13
- ALTSVC_IGNORE_IVARS = %i[@ssl].freeze
13
+ ALTSVC_IGNORE_IVARS = %i[ssl].freeze
14
14
 
15
15
  def send(request)
16
16
  request.headers["alt-used"] = @origin.authority if @parser && !@write_buffer.full? && match_altsvcs?(request.uri)
@@ -30,7 +30,7 @@ module HTTPX
30
30
  # +uri+
31
31
  def match_altsvcs?(uri)
32
32
  @origins.any? { |origin| altsvc_match?(uri, origin) } ||
33
- AltSvc.cached_altsvc(@origin).any? do |altsvc|
33
+ AltSvc.cached_altsvc(@origin.to_s).any? do |altsvc|
34
34
  origin = altsvc["origin"]
35
35
  altsvc_match?(origin, uri.origin)
36
36
  end
@@ -115,33 +115,45 @@ module HTTPX
115
115
  return
116
116
  end
117
117
 
118
- parse(altsvc) do |alt_origin, alt_params|
118
+ parse(altsvc, host: host) do |alt_origin, alt_params|
119
119
  alt_origin.host ||= host
120
120
  yield(alt_origin, origin, alt_params)
121
121
  end
122
122
  end
123
123
 
124
- def parse(altsvc)
125
- return enum_for(__method__, altsvc) unless block_given?
124
+ def parse(altsvc, host: nil)
125
+ return enum_for(__method__, altsvc, host: host) unless block_given?
126
126
 
127
127
  scanner = StringScanner.new(altsvc)
128
128
  until scanner.eos?
129
- alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
129
+ alt_services = [] #: Array[String]
130
130
 
131
- alt_params = []
131
+ while (alt_service = scanner.scan(/[^=]+=("[^"]+")/))
132
+ alt_services << alt_service
133
+ scanner.skip(/[, ]+/)
134
+ end
135
+
136
+ scanner.skip(/;/)
137
+
138
+ alt_params = [] #: Array[String]
132
139
  loop do
133
140
  alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
134
141
  alt_params << alt_param.strip if alt_param
135
142
  scanner.skip(/;/)
136
143
  break if scanner.eos? || scanner.scan(/ *, */)
137
144
  end
138
- alt_params = Hash[alt_params.map { |field| field.split("=", 2) }]
145
+ alt_params = Hash[alt_params.map do |field|
146
+ field.split("=", 2)
147
+ end]
139
148
 
140
- alt_proto, alt_authority = alt_service.split("=", 2)
141
- alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
142
- return unless alt_origin
149
+ alt_services.each do |alt_service|
150
+ alt_proto, alt_authority = alt_service.split("=", 2)
151
+ alt_origin = parse_altsvc_origin(alt_proto, alt_authority, host: host)
143
152
 
144
- yield(alt_origin, alt_params.merge("proto" => alt_proto))
153
+ next unless alt_origin
154
+
155
+ yield(alt_origin, alt_params.merge("proto" => alt_proto))
156
+ end
145
157
  end
146
158
  end
147
159
 
@@ -149,19 +161,21 @@ module HTTPX
149
161
  case alt_proto
150
162
  when "h2c"
151
163
  "http"
152
- when "h2"
164
+ when "h2", "quic", /^h3/
153
165
  "https"
154
166
  end
155
167
  end
156
168
 
157
- def parse_altsvc_origin(alt_proto, alt_origin)
169
+ def parse_altsvc_origin(alt_proto, alt_origin, host: nil)
158
170
  alt_scheme = parse_altsvc_scheme(alt_proto)
159
171
 
160
172
  return unless alt_scheme
161
173
 
162
174
  alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
163
175
 
164
- URI.parse("#{alt_scheme}://#{alt_origin}")
176
+ uri = URI.parse("#{alt_scheme}://#{alt_origin}")
177
+ uri.host = host if uri.host.nil? || uri.host.empty?
178
+ uri
165
179
  end
166
180
  end
167
181
  end
@@ -14,9 +14,9 @@ module HTTPX
14
14
  end
15
15
  end
16
16
 
17
- def emit(type, *args)
17
+ def emit(type, ...)
18
18
  log { "emit #{type.inspect} callbacks" } if respond_to?(:log)
19
- callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
19
+ callbacks(type).delete_if { |pr| :delete == pr.call(...) } # rubocop:disable Style/YodaCondition
20
20
  end
21
21
 
22
22
  def callbacks_for?(type)
@@ -5,18 +5,21 @@ module HTTPX
5
5
  # delegates to a default session when extended.
6
6
  module Chainable
7
7
  %w[head get post put delete trace options connect patch].each do |meth|
8
+ http_verb = meth.upcase
8
9
  class_eval(<<-MOD, __FILE__, __LINE__ + 1)
10
+ # sends a #{http_verb} request for the given +uri+, using +options+ for each.
9
11
  def #{meth}(*uri, **options) # def get(*uri, **options)
10
- request("#{meth.upcase}", uri, **options) # request("GET", uri, **options)
12
+ request("#{http_verb}", uri, **options) # request("GET", uri, **options)
11
13
  end # end
12
14
  MOD
13
15
  end
14
16
 
15
17
  # delegates to the default session (see HTTPX::Session#request).
16
- def request(*args, **options)
17
- branch(default_options).request(*args, **options)
18
+ def request(...)
19
+ branch(default_options).request(...)
18
20
  end
19
21
 
22
+ # returns a new session with the "Accept" header set to +type+.
20
23
  def accept(type)
21
24
  with(headers: { "accept" => String(type) })
22
25
  end
@@ -34,7 +34,7 @@ module HTTPX
34
34
  end
35
35
 
36
36
  def timeout
37
- @options.timeout[:operation_timeout]
37
+ @options.timeout.operation_timeout
38
38
  end
39
39
 
40
40
  def interests
@@ -48,10 +48,6 @@ module HTTPX
48
48
  end
49
49
 
50
50
  def reset
51
- if @ping_timer
52
- @ping_timer.cancel
53
- @ping_timer = nil
54
- end
55
51
  @max_requests = @options.max_requests || MAX_REQUESTS
56
52
  @parser.reset!
57
53
  @handshake_completed = false
@@ -92,14 +88,12 @@ module HTTPX
92
88
  @parser << data
93
89
  end
94
90
 
95
- def send(request)
96
- unless @max_requests.positive?
97
- @pending << request
91
+ def send(request, head = false)
92
+ unless @max_requests.positive? && @max_requests > @requests.size
93
+ head ? @pending.unshift(request) : @pending << request
98
94
  return
99
95
  end
100
96
 
101
- return if @requests.include?(request)
102
-
103
97
  @requests << request
104
98
  @pipelining = @max_concurrent_requests > 1 && @requests.size > 1
105
99
  end
@@ -132,9 +126,9 @@ module HTTPX
132
126
  headers = request.options.headers_class.new(h)
133
127
  response = request.options.response_class.new(request,
134
128
  @parser.status_code,
135
- @parser.http_version.join("."),
129
+ @parser.http_version,
136
130
  headers)
137
- request.log(color: :yellow) { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version.join(".")}" }
131
+ request.log(color: :yellow) { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version}" }
138
132
  request.log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{log_redact_headers(v)}" }.join("\n") }
139
133
 
140
134
  if response.content_length && response.content_length > request.options.max_response_body_size
@@ -187,7 +181,7 @@ module HTTPX
187
181
 
188
182
  @request = nil
189
183
  @requests.shift
190
- response = request.response
184
+ response = request.response #: Response
191
185
  emit(:response, request, response)
192
186
 
193
187
  if @parser.upgrade?
@@ -198,19 +192,17 @@ module HTTPX
198
192
 
199
193
  @parser.reset!
200
194
  @max_requests -= 1
201
- if response.is_a?(ErrorResponse)
202
- disable
203
- else
204
- manage_connection(request, response)
205
- end
195
+ manage_connection(request, response)
206
196
 
207
197
  if exhausted?
208
198
  @pending.unshift(*@requests)
209
199
  @requests.clear
210
200
 
211
201
  emit(:exhausted)
202
+ elsif @requests.any?
203
+ consume
212
204
  else
213
- send(@pending.shift) unless @pending.empty?
205
+ send(@pending.shift, true) unless @pending.empty?
214
206
  end
215
207
  end
216
208
 
@@ -23,9 +23,29 @@ module HTTPX
23
23
  end
24
24
 
25
25
  class GoawayError < Error
26
- def initialize(code = :no_error)
26
+ UNRECOVERABLE_ERRORS = %i[settings_timeout inadequate_security].freeze
27
+
28
+ def initialize(code, last_stream_id)
29
+ @code = code
30
+ @last_stream_id = last_stream_id
27
31
  super(0, code)
28
32
  end
33
+
34
+ def last_stream_id
35
+ return Float::INFINITY if unrecoverable?
36
+
37
+ @last_stream_id
38
+ end
39
+
40
+ def unrecoverable?
41
+ UNRECOVERABLE_ERRORS.include?(@code)
42
+ end
43
+ end
44
+
45
+ class RstStreamError < Error
46
+ end
47
+
48
+ class RefusedStreamError < RstStreamError
29
49
  end
30
50
 
31
51
  attr_reader :streams, :pending
@@ -48,9 +68,9 @@ module HTTPX
48
68
  end
49
69
 
50
70
  def timeout
51
- return @options.timeout[:operation_timeout] if @handshake_completed
71
+ return @options.timeout.operation_timeout if @handshake_completed
52
72
 
53
- @options.timeout[:settings_timeout]
73
+ @options.timeout.settings_timeout
54
74
  end
55
75
 
56
76
  def interests
@@ -77,27 +97,45 @@ module HTTPX
77
97
  return :r
78
98
  end
79
99
 
100
+ # only wait for writable if we're not waiting on more streams,
101
+ # or waiting on ping ACKs.
102
+ nothing_to_wait_for = @streams.empty? && @pings.empty?
103
+
80
104
  # there are pending bufferable requests
81
- return :w if !@pending.empty? && can_buffer_more_requests?
105
+ if !@pending.empty? && can_buffer_more_requests?
106
+ # only wait for writable if we're not waiting on more streams,
107
+ # or waiting on ping ACKs.
108
+ return nothing_to_wait_for ? :w : :rw
109
+ end
82
110
 
83
- # there are pending frames from the last run
84
- return :w unless @drains.empty?
111
+ unless @drains.empty?
112
+ return :w unless @buffer.empty?
113
+
114
+ streams_with_no_window = @drains.none? do |stream, _|
115
+ # if an existing stream has buffered bytes, it must wait for
116
+ # WINDOW_UPDATE frames
117
+ stream.send_buffer.empty?
118
+ end
119
+
120
+ return streams_with_no_window ? :r : :rw
121
+
122
+ end
85
123
 
86
124
  if @buffer.empty?
87
125
  # skip if no more requests or pings to process
88
- return if @streams.empty? && @pings.empty?
126
+ return if nothing_to_wait_for
89
127
 
90
128
  :r
91
129
  else
92
130
  # buffered frames
93
- :w
131
+ nothing_to_wait_for ? :w : :rw
94
132
  end
95
133
  end
96
134
 
97
135
  def close
98
136
  unless @connection.closed?
99
137
  @connection.goaway
100
- emit(:timeout, @options.timeout[:close_handshake_timeout])
138
+ emit(:timeout, @options.timeout.close_handshake_timeout)
101
139
  end
102
140
  emit(:close)
103
141
  end
@@ -149,18 +187,45 @@ module HTTPX
149
187
  end
150
188
 
151
189
  def handle_error(ex, request = nil)
152
- if ex.is_a?(OperationTimeoutError) && !@handshake_completed && @connection.state != :closed
153
- @connection.goaway(:settings_timeout, "closing due to settings timeout")
154
- emit(:close_handshake)
155
- settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
156
- settings_ex.set_backtrace(ex.backtrace)
157
- ex = settings_ex
158
- end
159
- while (req, _ = @streams.shift)
190
+ last_stream_id = Float::INFINITY
191
+ case ex
192
+ when OperationTimeoutError
193
+ if !@handshake_completed && @connection.state != :closed
194
+ @connection.goaway(:settings_timeout, "closing due to settings timeout")
195
+ emit(:close_handshake)
196
+ settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
197
+ settings_ex.set_backtrace(ex.backtrace)
198
+ ex = settings_ex
199
+ end
200
+ when GoawayError
201
+ last_stream_id = ex.last_stream_id
202
+ end
203
+
204
+ inflight_unprocessed_requests = [] #: Array[Request]
205
+
206
+ while (req, stream = @streams.shift)
207
+ @drains.delete(stream)
208
+
160
209
  next if request && request == req
161
210
 
211
+ if stream.id > last_stream_id
212
+ req.transition(:idle)
213
+ # unprocessed request
214
+ inflight_unprocessed_requests << req
215
+
216
+ next
217
+ end
218
+
162
219
  emit(:error, req, ex)
163
220
  end
221
+
222
+ if ex.is_a?(GoawayError) && !ex.unrecoverable?
223
+ # resend unprocessed requests on a different connection
224
+ @pending.unshift(*inflight_unprocessed_requests) if inflight_unprocessed_requests.any?
225
+ emit(:exhausted, ex) if @pending.any?
226
+ return
227
+ end
228
+
164
229
  while (req = @pending.shift)
165
230
  next if request && request == req
166
231
 
@@ -277,13 +342,23 @@ module HTTPX
277
342
  def join_body(stream, request)
278
343
  return if request.body.empty?
279
344
 
280
- chunk = @drains.delete(request) || request.drain_body
345
+ # the next chunk waits until the peer grants more window.
346
+ throw(:buffer_full) if @drains.key?(stream) && (!stream.send_buffer.empty? || !@connection.send_buffer.empty?)
347
+
348
+ chunk = @drains.delete(stream) || request.drain_body
281
349
  while chunk
282
350
  next_chunk = request.drain_body
283
351
  send_chunk(request, stream, chunk, next_chunk)
284
352
 
285
- if next_chunk && (@buffer.full? || request.body.unbounded_body?)
286
- @drains[request] = next_chunk
353
+ if next_chunk && (
354
+ # send window is full
355
+ @buffer.full? ||
356
+ # stream / chunked request, must write a chunk at a time
357
+ request.body.unbounded_body? ||
358
+ # peer window is full, must wait for WINDOW_UPDATE frame(s)
359
+ !stream.send_buffer.empty? || !@connection.send_buffer.empty?
360
+ )
361
+ @drains[stream] = next_chunk
287
362
  throw(:buffer_full)
288
363
  end
289
364
 
@@ -395,7 +470,14 @@ module HTTPX
395
470
  when :http_1_1_required
396
471
  emit(:error, request, error)
397
472
  else
398
- ex = Error.new(stream.id, error)
473
+ ex =
474
+ case error
475
+ when :refused_stream
476
+ RefusedStreamError.new(stream.id, error)
477
+ else
478
+ RstStreamError.new(stream.id, error)
479
+ end
480
+
399
481
  ex.set_backtrace(caller)
400
482
  response = ErrorResponse.new(request, ex)
401
483
  request.response = response
@@ -410,7 +492,10 @@ module HTTPX
410
492
  end
411
493
  end
412
494
  end
413
- send(@pending.shift) unless @pending.empty?
495
+
496
+ consume
497
+
498
+ send(@pending.shift, true) unless @pending.empty?
414
499
 
415
500
  return unless @streams.empty? && exhausted?
416
501
 
@@ -432,7 +517,7 @@ module HTTPX
432
517
  send_pending
433
518
  end
434
519
 
435
- def on_close(_last_frame, error, _payload)
520
+ def on_close(last_stream_id, error, _payload)
436
521
  is_connection_closed = @connection.closed?
437
522
  if error
438
523
  @buffer.clear if is_connection_closed
@@ -442,12 +527,11 @@ module HTTPX
442
527
  emit(:error, request, error)
443
528
  end
444
529
  else
445
- ex = GoawayError.new(error)
530
+ ex = GoawayError.new(error, last_stream_id)
446
531
  ex.set_backtrace(caller)
447
532
 
448
533
  handle_error(ex)
449
534
  teardown
450
-
451
535
  end
452
536
  end
453
537
  return unless is_connection_closed && @streams.empty?
@@ -539,8 +623,9 @@ module HTTPX
539
623
 
540
624
  def teardown(request = nil)
541
625
  if request
542
- @drains.delete(request)
543
- @streams.delete(request)
626
+ if (stream = @streams.delete(request))
627
+ @drains.delete(stream)
628
+ end
544
629
  else
545
630
  @drains.clear
546
631
  @streams.clear