httpx 1.6.2 → 1.7.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/0_11_0.md +3 -3
  3. data/doc/release_notes/1_6_3.md +47 -0
  4. data/doc/release_notes/1_7_0.md +149 -0
  5. data/lib/httpx/adapters/datadog.rb +1 -1
  6. data/lib/httpx/adapters/faraday.rb +1 -1
  7. data/lib/httpx/adapters/sentry.rb +1 -1
  8. data/lib/httpx/altsvc.rb +3 -1
  9. data/lib/httpx/connection/http1.rb +14 -15
  10. data/lib/httpx/connection/http2.rb +16 -15
  11. data/lib/httpx/connection.rb +118 -110
  12. data/lib/httpx/domain_name.rb +1 -1
  13. data/lib/httpx/extensions.rb +0 -14
  14. data/lib/httpx/headers.rb +2 -2
  15. data/lib/httpx/io/ssl.rb +1 -1
  16. data/lib/httpx/loggable.rb +14 -2
  17. data/lib/httpx/options.rb +60 -17
  18. data/lib/httpx/plugins/auth/digest.rb +44 -4
  19. data/lib/httpx/plugins/auth.rb +87 -4
  20. data/lib/httpx/plugins/aws_sdk_authentication.rb +0 -1
  21. data/lib/httpx/plugins/callbacks.rb +15 -1
  22. data/lib/httpx/plugins/cookies/cookie.rb +1 -0
  23. data/lib/httpx/plugins/digest_auth.rb +4 -5
  24. data/lib/httpx/plugins/fiber_concurrency.rb +16 -1
  25. data/lib/httpx/plugins/grpc/grpc_encoding.rb +1 -1
  26. data/lib/httpx/plugins/grpc.rb +2 -2
  27. data/lib/httpx/plugins/internal_telemetry.rb +1 -1
  28. data/lib/httpx/plugins/ntlm_auth.rb +5 -3
  29. data/lib/httpx/plugins/oauth.rb +162 -56
  30. data/lib/httpx/plugins/proxy/http.rb +37 -9
  31. data/lib/httpx/plugins/rate_limiter.rb +2 -2
  32. data/lib/httpx/plugins/response_cache/file_store.rb +1 -0
  33. data/lib/httpx/plugins/response_cache.rb +16 -9
  34. data/lib/httpx/plugins/retries.rb +55 -16
  35. data/lib/httpx/plugins/ssrf_filter.rb +1 -1
  36. data/lib/httpx/plugins/stream.rb +59 -8
  37. data/lib/httpx/plugins/stream_bidi.rb +87 -22
  38. data/lib/httpx/pool.rb +65 -21
  39. data/lib/httpx/request.rb +13 -14
  40. data/lib/httpx/resolver/https.rb +100 -34
  41. data/lib/httpx/resolver/multi.rb +12 -27
  42. data/lib/httpx/resolver/native.rb +68 -38
  43. data/lib/httpx/resolver/resolver.rb +46 -29
  44. data/lib/httpx/resolver/system.rb +63 -39
  45. data/lib/httpx/resolver.rb +97 -29
  46. data/lib/httpx/response/body.rb +2 -0
  47. data/lib/httpx/response.rb +22 -6
  48. data/lib/httpx/selector.rb +44 -20
  49. data/lib/httpx/session.rb +23 -33
  50. data/lib/httpx/transcoder/body.rb +1 -1
  51. data/lib/httpx/transcoder/deflate.rb +13 -8
  52. data/lib/httpx/transcoder/json.rb +1 -1
  53. data/lib/httpx/transcoder/multipart/decoder.rb +4 -4
  54. data/lib/httpx/transcoder/multipart/encoder.rb +1 -1
  55. data/lib/httpx/transcoder/multipart.rb +16 -8
  56. data/lib/httpx/transcoder/utils/body_reader.rb +1 -2
  57. data/lib/httpx/transcoder/utils/deflater.rb +1 -2
  58. data/lib/httpx/transcoder.rb +4 -6
  59. data/lib/httpx/version.rb +1 -1
  60. data/sig/altsvc.rbs +3 -0
  61. data/sig/chainable.rbs +3 -3
  62. data/sig/connection.rbs +13 -6
  63. data/sig/loggable.rbs +5 -1
  64. data/sig/options.rbs +6 -2
  65. data/sig/plugins/auth/digest.rbs +6 -0
  66. data/sig/plugins/auth.rbs +28 -4
  67. data/sig/plugins/basic_auth.rbs +3 -3
  68. data/sig/plugins/callbacks.rbs +3 -0
  69. data/sig/plugins/digest_auth.rbs +2 -4
  70. data/sig/plugins/fiber_concurrency.rbs +6 -0
  71. data/sig/plugins/ntlm_auth.rbs +2 -2
  72. data/sig/plugins/oauth.rbs +46 -15
  73. data/sig/plugins/rate_limiter.rbs +1 -1
  74. data/sig/plugins/response_cache/file_store.rbs +2 -0
  75. data/sig/plugins/response_cache.rbs +4 -0
  76. data/sig/plugins/retries.rbs +8 -2
  77. data/sig/plugins/stream.rbs +13 -3
  78. data/sig/plugins/stream_bidi.rbs +5 -7
  79. data/sig/pool.rbs +1 -1
  80. data/sig/resolver/https.rbs +7 -0
  81. data/sig/resolver/multi.rbs +2 -9
  82. data/sig/resolver/native.rbs +1 -1
  83. data/sig/resolver/resolver.rbs +9 -8
  84. data/sig/resolver/system.rbs +4 -2
  85. data/sig/resolver.rbs +12 -3
  86. data/sig/response.rbs +3 -0
  87. data/sig/selector.rbs +2 -0
  88. data/sig/session.rbs +8 -8
  89. data/sig/transcoder/multipart.rbs +4 -2
  90. data/sig/transcoder.rbs +5 -1
  91. metadata +5 -1
@@ -47,6 +47,17 @@ module HTTPX
47
47
  super || @state == :connecting || @state == :connected
48
48
  end
49
49
 
50
+ def force_close(*)
51
+ if @state == :connecting
52
+ # proxy connect related requests should not be reenqueed
53
+ @parser.reset!
54
+ @inflight -= @parser.pending.size
55
+ @parser.pending.clear
56
+ end
57
+
58
+ super
59
+ end
60
+
50
61
  private
51
62
 
52
63
  def handle_transition(nextstate)
@@ -64,23 +75,40 @@ module HTTPX
64
75
  parser = @parser
65
76
  parser.extend(ProxyParser)
66
77
  parser.on(:response, &method(:__http_on_connect))
67
- parser.on(:close) do |force|
78
+ parser.on(:close) do
68
79
  next unless @parser
69
80
 
70
- if force
71
- reset
72
- emit(:terminate)
73
- end
81
+ reset
82
+ disconnect
74
83
  end
75
84
  parser.on(:reset) do
76
85
  if parser.empty?
77
86
  reset
78
87
  else
79
- transition(:closing)
80
- transition(:closed)
88
+ enqueue_pending_requests_from_parser(parser)
89
+
90
+ initial_state = @state
91
+
92
+ reset
93
+
94
+ if @pending.empty?
95
+ @parser = nil
96
+ next
97
+ end
98
+ # keep parser state around due to proxy auth protocol;
99
+ # intermediate authenticated request is already inside
100
+ # the parser
101
+ parser = nil
102
+
103
+ if initial_state == :connecting
104
+ parser = @parser
105
+ @parser.reset
106
+ end
107
+
108
+ idling
109
+
110
+ @parser = parser
81
111
 
82
- parser.reset if @parser
83
- transition(:idle)
84
112
  transition(:connecting)
85
113
  end
86
114
  end
@@ -18,11 +18,11 @@ module HTTPX
18
18
  def configure(klass)
19
19
  klass.plugin(:retries,
20
20
  retry_change_requests: true,
21
- retry_on: method(:retry_on_rate_limited_response),
21
+ retry_on: method(:retry_on_rate_limited_response?),
22
22
  retry_after: method(:retry_after_rate_limit))
23
23
  end
24
24
 
25
- def retry_on_rate_limited_response(response)
25
+ def retry_on_rate_limited_response?(response)
26
26
  return false unless response.is_a?(Response)
27
27
 
28
28
  status = response.status
@@ -130,6 +130,7 @@ module HTTPX::Plugins
130
130
  response = request.options.response_class.new(request, status, version, response_headers)
131
131
  response.original_request = original_request
132
132
  response.finish!
133
+ response.mark_as_cached!
133
134
 
134
135
  IO.copy_stream(f, response.body)
135
136
 
@@ -118,7 +118,10 @@ module HTTPX
118
118
 
119
119
  response.copy_from_cached!
120
120
  elsif request.cacheable_verb? && ResponseCache.cacheable_response?(response)
121
- request.options.response_cache_store.set(request, response) unless response.cached?
121
+ unless response.cached?
122
+ log { "caching response for #{request.uri}..." }
123
+ request.options.response_cache_store.set(request, response)
124
+ end
122
125
  end
123
126
 
124
127
  response
@@ -147,7 +150,7 @@ module HTTPX
147
150
  request.headers.add("if-modified-since", last_modified)
148
151
  end
149
152
 
150
- if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
153
+ if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"])
151
154
  request.headers.add("if-none-match", etag)
152
155
  end
153
156
  end
@@ -204,7 +207,7 @@ module HTTPX
204
207
  # returns a unique cache key as a String identifying this request
205
208
  def response_cache_key
206
209
  @response_cache_key ||= begin
207
- keys = [@verb, @uri]
210
+ keys = [@verb, @uri.merge(path)]
208
211
 
209
212
  @options.supported_vary_headers.each do |field|
210
213
  value = @headers[field]
@@ -293,9 +296,7 @@ module HTTPX
293
296
  return @cache_control if defined?(@cache_control)
294
297
 
295
298
  @cache_control = begin
296
- return unless @headers.key?("cache-control")
297
-
298
- @headers["cache-control"].split(/ *, */)
299
+ @headers["cache-control"].split(/ *, */) if @headers.key?("cache-control")
299
300
  end
300
301
  end
301
302
 
@@ -304,9 +305,7 @@ module HTTPX
304
305
  return @vary if defined?(@vary)
305
306
 
306
307
  @vary = begin
307
- return unless @headers.key?("vary")
308
-
309
- @headers["vary"].split(/ *, */).map(&:downcase)
308
+ @headers["vary"].split(/ *, */).map(&:downcase) if @headers.key?("vary")
310
309
  end
311
310
  end
312
311
 
@@ -327,6 +326,14 @@ module HTTPX
327
326
  Time.now
328
327
  end
329
328
  end
329
+
330
+ module ResponseBodyMethods
331
+ def decode_chunk(chunk)
332
+ return chunk if @response.cached?
333
+
334
+ super
335
+ end
336
+ end
330
337
  end
331
338
  register_plugin :response_cache, ResponseCache
332
339
  end
@@ -36,15 +36,33 @@ module HTTPX
36
36
  Parser::Error,
37
37
  TimeoutError,
38
38
  ]).freeze
39
- DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
40
39
 
41
- if ENV.key?("HTTPX_NO_JITTER")
42
- def self.extra_options(options)
43
- options.merge(max_retries: MAX_RETRIES)
40
+ DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }.freeze
41
+
42
+ # list of supported backoff algorithms
43
+ BACKOFF_ALGORITHMS = %i[exponential_backoff polynomial_backoff].freeze
44
+
45
+ class << self
46
+ if ENV.key?("HTTPX_NO_JITTER")
47
+ def extra_options(options)
48
+ options.merge(max_retries: MAX_RETRIES)
49
+ end
50
+ else
51
+ def extra_options(options)
52
+ options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
53
+ end
54
+ end
55
+
56
+ # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy.
57
+ def retry_after_polynomial_backoff(request, _)
58
+ offset = request.options.max_retries - request.retries
59
+ 2 * (offset - 1)
44
60
  end
45
- else
46
- def self.extra_options(options)
47
- options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
61
+
62
+ # returns the time to wait before resending +request+ as per the exponential backoff retry strategy.
63
+ def retry_after_exponential_backoff(request, _)
64
+ offset = request.options.max_retries - request.retries
65
+ (offset - 1) * 2
48
66
  end
49
67
  end
50
68
 
@@ -53,6 +71,7 @@ module HTTPX
53
71
  # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
54
72
  # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
55
73
  # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
74
+ # or the name of a supported backoff algorithm (i.e. <tt>:exponential_backoff</tt>).
56
75
  # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
57
76
  # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
58
77
  # (i.e. <tt>->(res) { ... }</tt>).
@@ -60,10 +79,26 @@ module HTTPX
60
79
  private
61
80
 
62
81
  def option_retry_after(value)
63
- # return early if callable
64
- unless value.respond_to?(:call)
65
- value = Float(value)
66
- raise TypeError, ":retry_after must be positive" unless value.positive?
82
+ if value.respond_to?(:call)
83
+ value1 = value
84
+ value1 = value1.method(:call) unless value1.respond_to?(:arity)
85
+
86
+ # allow ->(*) arity as well, which is < 0
87
+ raise TypeError, "`:retry_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
88
+ 1, 2
89
+ )
90
+
91
+ else
92
+ case value
93
+ when Symbol
94
+ raise TypeError, "`retry_after`: `#{value}` is not a supported backoff algorithm" unless BACKOFF_ALGORITHMS.include?(value)
95
+
96
+ value = Retries.method(:"retry_after_#{value}")
97
+
98
+ else
99
+ value = Float(value)
100
+ raise TypeError, "`:retry_after` must be positive" unless value.positive?
101
+ end
67
102
  end
68
103
 
69
104
  value
@@ -112,14 +147,13 @@ module HTTPX
112
147
  (
113
148
  response.is_a?(ErrorResponse) && retryable_error?(response.error)
114
149
  ) ||
115
- (
116
- options.retry_on && options.retry_on.call(response)
117
- )
150
+
151
+ options.retry_on&.call(response)
152
+
118
153
  )
119
154
  try_partial_retry(request, response)
120
155
  log { "failed to get response, #{request.retries} tries to go..." }
121
- request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
122
- request.transition(:idle)
156
+ prepare_to_retry(request, response)
123
157
 
124
158
  retry_after = options.retry_after
125
159
  retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
@@ -165,6 +199,11 @@ module HTTPX
165
199
  super && !request.retries.positive?
166
200
  end
167
201
 
202
+ def prepare_to_retry(request, _response)
203
+ request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
204
+ request.transition(:idle)
205
+ end
206
+
168
207
  #
169
208
  # Attempt to set the request to perform a partial range request.
170
209
  # This happens if the peer server accepts byte-range requests, and
@@ -16,7 +16,7 @@ module HTTPX
16
16
  mask_addr = @mask_addr
17
17
  raise "Invalid mask" if mask_addr.zero?
18
18
 
19
- mask_addr >>= 1 while (mask_addr & 0x1).zero?
19
+ mask_addr >>= 1 while mask_addr.nobits?(0x1)
20
20
 
21
21
  length = 0
22
22
  while mask_addr & 0x1 == 0x1
@@ -55,9 +55,9 @@ module HTTPX
55
55
  line << chunk
56
56
 
57
57
  while (idx = line.index("\n"))
58
- yield line.byteslice(0..idx - 1)
58
+ yield line.byteslice(0..(idx - 1))
59
59
 
60
- line = line.byteslice(idx + 1..-1)
60
+ line = line.byteslice((idx + 1)..-1)
61
61
  end
62
62
  end
63
63
 
@@ -121,20 +121,71 @@ module HTTPX
121
121
  # https://gitlab.com/os85/httpx/wikis/Stream
122
122
  #
123
123
  module Stream
124
+ STREAM_REQUEST_OPTIONS = { timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 }.freeze }.freeze
125
+
124
126
  def self.extra_options(options)
125
- options.merge(timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 })
127
+ options.merge(
128
+ stream: false,
129
+ timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 },
130
+ stream_response_class: Class.new(StreamResponse, &Options::SET_TEMPORARY_NAME).freeze
131
+ )
132
+ end
133
+
134
+ # adds support for the following options:
135
+ #
136
+ # :stream :: whether the request to process should be handled as a stream (defaults to <tt>false</tt>).
137
+ # :stream_response_class :: Class used to build the stream response object.
138
+ module OptionsMethods
139
+ def option_stream(val)
140
+ val
141
+ end
142
+
143
+ def option_stream_response_class(value)
144
+ value
145
+ end
146
+
147
+ def extend_with_plugin_classes(pl)
148
+ return super unless defined?(pl::StreamResponseMethods)
149
+
150
+ @stream_response_class = @stream_response_class.dup
151
+ Options::SET_TEMPORARY_NAME[@stream_response_class, pl]
152
+ @stream_response_class.__send__(:include, pl::StreamResponseMethods) if defined?(pl::StreamResponseMethods)
153
+
154
+ super
155
+ end
126
156
  end
127
157
 
128
158
  module InstanceMethods
129
- def request(*args, stream: false, **options)
130
- return super(*args, **options) unless stream
159
+ def request(*args, **options)
160
+ if args.first.is_a?(Request)
161
+ requests = args
162
+
163
+ request = requests.first
164
+
165
+ unless request.options.stream && !request.stream
166
+ if options[:stream]
167
+ warn "passing `stream: true` with a request obkect is not supported anymore. " \
168
+ "You can instead build the request object with `stream :true`"
169
+ end
170
+ return super
171
+ end
172
+ else
173
+ return super unless options[:stream]
174
+
175
+ requests = build_requests(*args, options)
176
+
177
+ request = requests.first
178
+ end
131
179
 
132
- requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
133
180
  raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
134
181
 
135
- request = requests.first
182
+ @options.stream_response_class.new(request, self)
183
+ end
184
+
185
+ def build_request(verb, uri, params = EMPTY_HASH, options = @options)
186
+ return super unless params[:stream]
136
187
 
137
- StreamResponse.new(request, self)
188
+ super(verb, uri, params, options.merge(STREAM_REQUEST_OPTIONS.merge(stream: true)))
138
189
  end
139
190
  end
140
191
 
@@ -16,7 +16,7 @@ module HTTPX
16
16
  # The streams keeps send DATA frames while there's data; when they're ain't,
17
17
  # the stream is kept open; it must be explicitly closed by the end user.
18
18
  #
19
- class HTTP2Bidi < Connection::HTTP2
19
+ module HTTP2Methods
20
20
  def initialize(*)
21
21
  super
22
22
  @lock = Thread::Mutex.new
@@ -26,6 +26,8 @@ module HTTPX
26
26
  class_eval(<<-METH, __FILE__, __LINE__ + 1)
27
27
  # lock.aware version of +#{lock_meth}+
28
28
  def #{lock_meth}(*) # def close(*)
29
+ return super unless @options.stream
30
+
29
31
  return super if @lock.owned?
30
32
 
31
33
  # small race condition between
@@ -43,6 +45,8 @@ module HTTPX
43
45
  class_eval(<<-METH, __FILE__, __LINE__ + 1)
44
46
  # lock.aware version of +#{lock_meth}+
45
47
  private def #{lock_meth}(*) # private def join_headers(*)
48
+ return super unless @options.stream
49
+
46
50
  return super if @lock.owned?
47
51
 
48
52
  # small race condition between
@@ -55,6 +59,8 @@ module HTTPX
55
59
  end
56
60
 
57
61
  def handle_stream(stream, request)
62
+ return super unless @options.stream
63
+
58
64
  request.on(:body) do
59
65
  next unless request.headers_sent
60
66
 
@@ -67,6 +73,8 @@ module HTTPX
67
73
 
68
74
  # when there ain't more chunks, it makes the buffer as full.
69
75
  def send_chunk(request, stream, chunk, next_chunk)
76
+ return super unless @options.stream
77
+
70
78
  super
71
79
 
72
80
  return if next_chunk
@@ -77,39 +85,60 @@ module HTTPX
77
85
 
78
86
  # sets end-stream flag when the request is closed.
79
87
  def end_stream?(request, next_chunk)
88
+ return super unless @options.stream
89
+
80
90
  request.closed? && next_chunk.nil?
81
91
  end
82
92
  end
83
93
 
84
- # BidiBuffer is a Buffer which can be receive data from threads othr
85
- # than the thread of the corresponding Connection/Session.
94
+ # BidiBuffer is a thread-safe Buffer which can receive data from any thread.
86
95
  #
87
- # It synchronizes access to a secondary internal +@oob_buffer+, which periodically
88
- # is reconciled to the main internal +@buffer+.
96
+ # It uses a dual-buffer strategy with mutex protection:
97
+ # - +@buffer+ is the main buffer, protected by +@buffer_mutex+
98
+ # - +@oob_buffer+ receives data when +@buffer_mutex+ is contended
99
+ #
100
+ # This allows non-blocking writes from any thread while maintaining thread safety.
89
101
  class BidiBuffer < Buffer
90
102
  def initialize(*)
91
103
  super
92
- @parent_thread = Thread.current
104
+ @buffer_mutex = Thread::Mutex.new
93
105
  @oob_mutex = Thread::Mutex.new
94
106
  @oob_buffer = "".b
95
107
  end
96
108
 
97
- # buffers the +chunk+ to be sent
109
+ # buffers the +chunk+ to be sent (thread-safe, non-blocking)
98
110
  def <<(chunk)
99
- return super if Thread.current == @parent_thread
100
-
101
- @oob_mutex.synchronize { @oob_buffer << chunk }
111
+ if @buffer_mutex.try_lock
112
+ begin
113
+ super
114
+ ensure
115
+ @buffer_mutex.unlock
116
+ end
117
+ else
118
+ # another thread holds the lock, use OOB buffer to avoid blocking
119
+ @oob_mutex.synchronize { @oob_buffer << chunk }
120
+ end
102
121
  end
103
122
 
104
- # reconciles the main and secondary buffer (which receives data from other threads).
123
+ # reconciles the main and secondary buffer (thread-safe, callable from any thread).
105
124
  def rebuffer
106
- raise Error, "can only rebuffer while waiting on a response" unless Thread.current == @parent_thread
125
+ @buffer_mutex.synchronize do
126
+ @oob_mutex.synchronize do
127
+ return if @oob_buffer.empty?
107
128
 
108
- @oob_mutex.synchronize do
109
- @buffer << @oob_buffer
110
- @oob_buffer.clear
129
+ @buffer << @oob_buffer
130
+ @oob_buffer.clear
131
+ end
111
132
  end
112
133
  end
134
+
135
+ Buffer.instance_methods - Object.instance_methods - %i[<<].each do |meth|
136
+ class_eval(<<-MOD, __FILE__, __LINE__ + 1)
137
+ def #{meth}(*) # def empty?
138
+ @buffer_mutex.synchronize { super }
139
+ end
140
+ MOD
141
+ end
113
142
  end
114
143
 
115
144
  # Proxy to wake up the session main loop when one
@@ -117,8 +146,11 @@ module HTTPX
117
146
  # which allows it to be registered in the selector alongside actual HTTP-based
118
147
  # HTTPX::Connection objects.
119
148
  class Signal
149
+ attr_reader :error
150
+
120
151
  def initialize
121
152
  @closed = false
153
+ @error = nil
122
154
  @pipe_read, @pipe_write = IO.pipe
123
155
  end
124
156
 
@@ -153,12 +185,23 @@ module HTTPX
153
185
 
154
186
  def timeout; end
155
187
 
188
+ def inflight?
189
+ !@closed
190
+ end
191
+
156
192
  def terminate
193
+ return if @closed
194
+
157
195
  @pipe_write.close
158
196
  @pipe_read.close
159
197
  @closed = true
160
198
  end
161
199
 
200
+ def on_error(error)
201
+ @error = error
202
+ terminate
203
+ end
204
+
162
205
  # noop (the owner connection will take of it)
163
206
  def handle_socket_timeout(interval); end
164
207
  end
@@ -182,17 +225,22 @@ module HTTPX
182
225
  def close(selector = Selector.new)
183
226
  @signal.terminate
184
227
  selector.deregister(@signal)
185
- super(selector)
228
+ super
186
229
  end
187
230
 
188
231
  def select_connection(connection, selector)
232
+ return super unless connection.options.stream
233
+
189
234
  super
190
235
  selector.register(@signal)
191
236
  connection.signal = @signal
192
237
  end
193
238
 
194
239
  def deselect_connection(connection, *)
240
+ return super unless connection.options.stream
241
+
195
242
  super
243
+
196
244
  connection.signal = nil
197
245
  end
198
246
  end
@@ -210,10 +258,14 @@ module HTTPX
210
258
  end
211
259
 
212
260
  def closed?
261
+ return super unless @options.stream
262
+
213
263
  @closed
214
264
  end
215
265
 
216
266
  def can_buffer?
267
+ return super unless @options.stream
268
+
217
269
  super && @state != :waiting_for_chunk
218
270
  end
219
271
 
@@ -221,6 +273,8 @@ module HTTPX
221
273
  # +:waiting_for_chunk+ state, which the request transitions to once payload
222
274
  # is buffered.
223
275
  def transition(nextstate)
276
+ return super unless @options.stream
277
+
224
278
  headers_sent = @headers_sent
225
279
 
226
280
  case nextstate
@@ -255,6 +309,8 @@ module HTTPX
255
309
  end
256
310
 
257
311
  def close
312
+ return super unless @options.stream
313
+
258
314
  @mutex.synchronize do
259
315
  return if @closed
260
316
 
@@ -269,10 +325,12 @@ module HTTPX
269
325
  module RequestBodyMethods
270
326
  def initialize(*, **)
271
327
  super
272
- @headers.delete("content-length")
328
+ @headers.delete("content-length") if @options.stream
273
329
  end
274
330
 
275
331
  def empty?
332
+ return super unless @options.stream
333
+
276
334
  false
277
335
  end
278
336
  end
@@ -284,25 +342,32 @@ module HTTPX
284
342
 
285
343
  def initialize(*)
286
344
  super
345
+
346
+ return unless @options.stream
347
+
287
348
  @write_buffer = BidiBuffer.new(@options.buffer_size)
288
349
  end
289
350
 
290
351
  # rebuffers the +@write_buffer+ before calculating interests.
291
352
  def interests
353
+ return super unless @options.stream
354
+
292
355
  @write_buffer.rebuffer
293
356
 
294
357
  super
295
358
  end
296
359
 
297
- private
298
-
299
- def parser_type(protocol)
300
- return HTTP2Bidi if protocol == "h2"
360
+ def call
361
+ return super unless @options.stream && (error = @signal.error)
301
362
 
302
- super
363
+ on_error(error)
303
364
  end
304
365
 
366
+ private
367
+
305
368
  def set_parser_callbacks(parser)
369
+ return super unless @options.stream
370
+
306
371
  super
307
372
  parser.on(:flush_buffer) do
308
373
  @signal.wakeup if @signal