httpx 0.9.0 → 0.10.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +48 -0
  3. data/README.md +2 -0
  4. data/doc/release_notes/0_10_0.md +66 -0
  5. data/lib/httpx.rb +2 -0
  6. data/lib/httpx/adapters/faraday.rb +1 -1
  7. data/lib/httpx/chainable.rb +2 -2
  8. data/lib/httpx/connection.rb +3 -9
  9. data/lib/httpx/connection/http1.rb +1 -1
  10. data/lib/httpx/domain_name.rb +440 -0
  11. data/lib/httpx/errors.rb +1 -0
  12. data/lib/httpx/extensions.rb +21 -1
  13. data/lib/httpx/io/ssl.rb +0 -1
  14. data/lib/httpx/io/tcp.rb +6 -5
  15. data/lib/httpx/io/udp.rb +4 -1
  16. data/lib/httpx/options.rb +2 -0
  17. data/lib/httpx/parser/http1.rb +14 -17
  18. data/lib/httpx/plugins/compression.rb +28 -63
  19. data/lib/httpx/plugins/compression/brotli.rb +10 -14
  20. data/lib/httpx/plugins/compression/deflate.rb +7 -6
  21. data/lib/httpx/plugins/compression/gzip.rb +23 -5
  22. data/lib/httpx/plugins/cookies.rb +21 -60
  23. data/lib/httpx/plugins/cookies/cookie.rb +173 -0
  24. data/lib/httpx/plugins/cookies/jar.rb +74 -0
  25. data/lib/httpx/plugins/cookies/set_cookie_parser.rb +142 -0
  26. data/lib/httpx/plugins/expect.rb +3 -5
  27. data/lib/httpx/plugins/follow_redirects.rb +20 -2
  28. data/lib/httpx/plugins/h2c.rb +1 -1
  29. data/lib/httpx/plugins/multipart.rb +0 -8
  30. data/lib/httpx/plugins/persistent.rb +6 -1
  31. data/lib/httpx/plugins/proxy/socks4.rb +3 -1
  32. data/lib/httpx/plugins/rate_limiter.rb +51 -0
  33. data/lib/httpx/plugins/retries.rb +3 -2
  34. data/lib/httpx/plugins/stream.rb +109 -13
  35. data/lib/httpx/pool.rb +6 -6
  36. data/lib/httpx/request.rb +7 -19
  37. data/lib/httpx/resolver/https.rb +7 -2
  38. data/lib/httpx/resolver/native.rb +7 -3
  39. data/lib/httpx/response.rb +16 -23
  40. data/lib/httpx/selector.rb +2 -4
  41. data/lib/httpx/session.rb +17 -11
  42. data/lib/httpx/transcoder/chunker.rb +0 -2
  43. data/lib/httpx/transcoder/form.rb +0 -6
  44. data/lib/httpx/transcoder/json.rb +0 -4
  45. data/lib/httpx/utils.rb +45 -0
  46. data/lib/httpx/version.rb +1 -1
  47. data/sig/buffer.rbs +24 -0
  48. data/sig/callbacks.rbs +14 -0
  49. data/sig/chainable.rbs +37 -0
  50. data/sig/connection.rbs +2 -0
  51. data/sig/connection/http2.rbs +4 -0
  52. data/sig/domain_name.rbs +17 -0
  53. data/sig/errors.rbs +3 -0
  54. data/sig/headers.rbs +42 -0
  55. data/sig/httpx.rbs +14 -0
  56. data/sig/loggable.rbs +11 -0
  57. data/sig/missing.rbs +12 -0
  58. data/sig/options.rbs +118 -0
  59. data/sig/parser/http1.rbs +50 -0
  60. data/sig/plugins/authentication.rbs +11 -0
  61. data/sig/plugins/basic_authentication.rbs +13 -0
  62. data/sig/plugins/compression.rbs +55 -0
  63. data/sig/plugins/compression/brotli.rbs +21 -0
  64. data/sig/plugins/compression/deflate.rbs +17 -0
  65. data/sig/plugins/compression/gzip.rbs +29 -0
  66. data/sig/plugins/cookies.rbs +26 -0
  67. data/sig/plugins/cookies/cookie.rbs +50 -0
  68. data/sig/plugins/cookies/jar.rbs +27 -0
  69. data/sig/plugins/digest_authentication.rbs +33 -0
  70. data/sig/plugins/expect.rbs +19 -0
  71. data/sig/plugins/follow_redirects.rbs +37 -0
  72. data/sig/plugins/h2c.rbs +26 -0
  73. data/sig/plugins/multipart.rbs +19 -0
  74. data/sig/plugins/persistent.rbs +17 -0
  75. data/sig/plugins/proxy.rbs +47 -0
  76. data/sig/plugins/proxy/http.rbs +14 -0
  77. data/sig/plugins/proxy/socks4.rbs +33 -0
  78. data/sig/plugins/proxy/socks5.rbs +36 -0
  79. data/sig/plugins/proxy/ssh.rbs +18 -0
  80. data/sig/plugins/push_promise.rbs +22 -0
  81. data/sig/plugins/rate_limiter.rbs +11 -0
  82. data/sig/plugins/retries.rbs +48 -0
  83. data/sig/plugins/stream.rbs +39 -0
  84. data/sig/pool.rbs +2 -0
  85. data/sig/registry.rbs +9 -0
  86. data/sig/request.rbs +61 -0
  87. data/sig/response.rbs +87 -0
  88. data/sig/session.rbs +49 -0
  89. data/sig/test.rbs +9 -0
  90. data/sig/timeout.rbs +29 -0
  91. data/sig/transcoder.rbs +16 -0
  92. data/sig/transcoder/body.rbs +18 -0
  93. data/sig/transcoder/chunker.rbs +32 -0
  94. data/sig/transcoder/form.rbs +16 -0
  95. data/sig/transcoder/json.rbs +14 -0
  96. metadata +60 -17
@@ -33,11 +33,8 @@ module HTTPX
33
33
  super
34
34
  return if @body.nil?
35
35
 
36
- if (threshold = options.expect_threshold_size)
37
- unless unbounded_body?
38
- return if @body.bytesize < threshold
39
- end
40
- end
36
+ threshold = options.expect_threshold_size
37
+ return if threshold && !unbounded_body? && @body.bytesize < threshold
41
38
 
42
39
  @headers["expect"] = "100-continue"
43
40
  end
@@ -63,6 +60,7 @@ module HTTPX
63
60
  return unless response
64
61
 
65
62
  if response.status == 417 && request.headers.key?("expect")
63
+ response.close
66
64
  request.headers.delete("expect")
67
65
  request.transition(:idle)
68
66
  connection = find_connection(request, connections, options)
@@ -59,8 +59,26 @@ module HTTPX
59
59
  return ErrorResponse.new(request, error, options)
60
60
  end
61
61
 
62
- connection = find_connection(retry_request, connections, options)
63
- connection.send(retry_request)
62
+ retry_after = response.headers["retry-after"]
63
+
64
+ if retry_after
65
+ # Servers send the "Retry-After" header field to indicate how long the
66
+ # user agent ought to wait before making a follow-up request.
67
+ # When sent with any 3xx (Redirection) response, Retry-After indicates
68
+ # the minimum time that the user agent is asked to wait before issuing
69
+ # the redirected request.
70
+ #
71
+ retry_after = Utils.parse_retry_after(retry_after)
72
+
73
+ log { "redirecting after #{retry_after} secs..." }
74
+ pool.after(retry_after) do
75
+ connection = find_connection(retry_request, connections, options)
76
+ connection.send(retry_request)
77
+ end
78
+ else
79
+ connection = find_connection(retry_request, connections, options)
80
+ connection.send(retry_request)
81
+ end
64
82
  nil
65
83
  end
66
84
 
@@ -73,7 +73,7 @@ module HTTPX
73
73
 
74
74
  # clean up data left behind in the buffer, if the server started
75
75
  # sending frames
76
- data = response.to_s
76
+ data = response.read
77
77
  @connection << data
78
78
  end
79
79
  end
@@ -29,14 +29,6 @@ module HTTPX
29
29
  def bytesize
30
30
  @raw.content_length
31
31
  end
32
-
33
- def force_encoding(*args)
34
- @raw.to_s.force_encoding(*args)
35
- end
36
-
37
- def to_str
38
- @raw.to_s
39
- end
40
32
  end
41
33
 
42
34
  def encode(form)
@@ -19,7 +19,12 @@ module HTTPX
19
19
  #
20
20
  module Persistent
21
21
  def self.load_dependencies(klass)
22
- klass.plugin(:retries, max_retries: 1, retry_change_requests: true)
22
+ max_retries = if klass.default_options.respond_to?(:max_retries)
23
+ [klass.default_options.max_retries, 1].max
24
+ else
25
+ 1
26
+ end
27
+ klass.plugin(:retries, max_retries: max_retries, retry_change_requests: true)
23
28
  end
24
29
 
25
30
  def self.extra_options(options)
@@ -91,6 +91,8 @@ module HTTPX
91
91
  end
92
92
 
93
93
  module Packet
94
+ using(RegexpExtensions) unless Regexp.method_defined?(:match?)
95
+
94
96
  module_function
95
97
 
96
98
  def connect(parameters, uri)
@@ -101,7 +103,7 @@ module HTTPX
101
103
 
102
104
  packet << [ip.to_i].pack("N")
103
105
  rescue IPAddr::InvalidAddressError
104
- if parameters.uri.scheme =~ /^socks4a?$/
106
+ if /^socks4a?$/.match?(parameters.uri.scheme)
105
107
  # resolv defaults to IPv4, and socks4 doesn't support IPv6 otherwise
106
108
  ip = IPAddr.new(Resolv.getaddress(uri.host))
107
109
  packet << [ip.to_i].pack("N")
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin adds support for retrying requests when the request:
7
+ #
8
+ # * is rate limited;
9
+ # * when the server is unavailable (503);
10
+ # * when a 3xx request comes with a "retry-after" value
11
+ #
12
+ # https://gitlab.com/honeyryderchuck/httpx/wikis/RateLimiter
13
+ #
14
+ module RateLimiter
15
+ class << self
16
+ RATE_LIMIT_CODES = [429, 503].freeze
17
+
18
+ def load_dependencies(klass)
19
+ klass.plugin(:retries,
20
+ retry_change_requests: true,
21
+ retry_on: method(:retry_on_rate_limited_response),
22
+ retry_after: method(:retry_after_rate_limit))
23
+ end
24
+
25
+ def retry_on_rate_limited_response(response)
26
+ status = response.status
27
+
28
+ RATE_LIMIT_CODES.include?(status)
29
+ end
30
+
31
+ # Servers send the "Retry-After" header field to indicate how long the
32
+ # user agent ought to wait before making a follow-up request. When
33
+ # sent with a 503 (Service Unavailable) response, Retry-After indicates
34
+ # how long the service is expected to be unavailable to the client.
35
+ # When sent with any 3xx (Redirection) response, Retry-After indicates
36
+ # the minimum time that the user agent is asked to wait before issuing
37
+ # the redirected request.
38
+ #
39
+ def retry_after_rate_limit(_, response)
40
+ retry_after = response.headers["retry-after"]
41
+
42
+ return unless retry_after
43
+
44
+ Utils.parse_retry_after(retry_after)
45
+ end
46
+ end
47
+ end
48
+
49
+ register_plugin :rate_limiter, RateLimiter
50
+ end
51
+ end
@@ -75,14 +75,15 @@ module HTTPX
75
75
  )
76
76
  # rubocop:enable Style/MultilineTernaryOperator
77
77
  )
78
-
78
+ response.close if response.respond_to?(:close)
79
79
  request.retries -= 1
80
80
  log { "failed to get response, #{request.retries} tries to go..." }
81
81
  request.transition(:idle)
82
82
 
83
83
  retry_after = options.retry_after
84
+ retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
85
+
84
86
  if retry_after
85
- retry_after = retry_after.call(request) if retry_after.respond_to?(:call)
86
87
 
87
88
  log { "retrying after #{retry_after} secs..." }
88
89
  pool.after(retry_after) do
@@ -7,27 +7,123 @@ module HTTPX
7
7
  #
8
8
  module Stream
9
9
  module InstanceMethods
10
- def stream
11
- headers("accept" => "text/event-stream",
12
- "cache-control" => "no-cache")
10
+ private
11
+
12
+ def request(*args, stream: false, **options)
13
+ return super(*args, **options) unless stream
14
+
15
+ requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
16
+
17
+ raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
18
+
19
+ StreamResponse.new(requests.first, self)
13
20
  end
14
21
  end
15
22
 
23
+ module RequestMethods
24
+ attr_accessor :stream
25
+ end
26
+
16
27
  module ResponseMethods
17
- def complete?
18
- super ||
19
- stream? &&
20
- @stream_complete
28
+ def stream
29
+ @request.stream
30
+ end
31
+ end
32
+
33
+ module ResponseBodyMethods
34
+ def initialize(*, **)
35
+ super
36
+ @stream = @response.stream
37
+ end
38
+
39
+ def write(chunk)
40
+ return super unless @stream
41
+
42
+ @stream.on_chunk(chunk.to_s.dup)
43
+ end
44
+
45
+ private
46
+
47
+ def transition(*)
48
+ return if @stream
49
+
50
+ super
51
+ end
52
+ end
53
+
54
+ class StreamResponse
55
+ def initialize(request, session)
56
+ @request = request
57
+ @session = session
58
+ @options = @request.options
59
+ end
60
+
61
+ def each(&block)
62
+ return enum_for(__method__) unless block_given?
63
+
64
+ raise Error, "response already streamed" if @response
65
+
66
+ @request.stream = self
67
+
68
+ begin
69
+ @on_chunk = block
70
+
71
+ response.raise_for_status
72
+ response.close
73
+ ensure
74
+ @on_chunk = nil
75
+ end
76
+ end
77
+
78
+ def each_line
79
+ return enum_for(__method__) unless block_given?
80
+
81
+ line = +""
82
+
83
+ each do |chunk|
84
+ line << chunk
85
+
86
+ while (idx = line.index("\n"))
87
+ yield line.byteslice(0..idx - 1)
88
+
89
+ line = line.byteslice(idx + 1..-1)
90
+ end
91
+ end
92
+ end
93
+
94
+ # This is a ghost method. It's to be used ONLY internally, when processing streams
95
+ def on_chunk(chunk)
96
+ raise NoMethodError unless @on_chunk
97
+
98
+ @on_chunk.call(chunk)
99
+ end
100
+
101
+ # :nocov:
102
+ def inspect
103
+ "#<StreamResponse:#{object_id}>"
104
+ end
105
+ # :nocov:
106
+
107
+ def to_s
108
+ response.to_s
109
+ end
110
+
111
+ private
112
+
113
+ def response
114
+ @response ||= @session.__send__(:send_requests, @request, @options).first
21
115
  end
22
116
 
23
- def stream?
24
- @headers["content-type"].start_with?("text/event-stream")
117
+ def respond_to_missing?(*args)
118
+ @options.response_class.respond_to?(*args) || super
25
119
  end
26
120
 
27
- def <<(data)
28
- res = super
29
- @stream_complete = true if String(data).end_with?("\n\n")
30
- res
121
+ def method_missing(meth, *args, &block)
122
+ if @options.response_class.public_method_defined?(meth)
123
+ response.__send__(meth, *args, &block)
124
+ else
125
+ super
126
+ end
31
127
  end
32
128
  end
33
129
  end
@@ -37,20 +37,22 @@ module HTTPX
37
37
 
38
38
  @timers.fire
39
39
  end
40
- rescue Interrupt
41
- @connections.each(&:reset)
42
- raise
43
40
  rescue StandardError => e
44
41
  @connections.each do |connection|
45
42
  connection.emit(:error, e)
46
43
  end
44
+ rescue Exception # rubocop:disable Lint/RescueException
45
+ @connections.each(&:reset)
46
+ raise
47
47
  end
48
48
 
49
49
  def close(connections = @connections)
50
+ return if connections.empty?
51
+
50
52
  @timers.cancel
51
53
  connections = connections.reject(&:inflight?)
52
54
  connections.each(&:close)
53
- next_tick until connections.none? { |c| @connections.include?(c) }
55
+ next_tick until connections.none? { |c| c.state != :idle && @connections.include?(c) }
54
56
  @resolvers.each_value do |resolver|
55
57
  resolver.close unless resolver.closed?
56
58
  end if @connections.empty?
@@ -166,7 +168,6 @@ module HTTPX
166
168
  resolver.on(:error, &method(:on_resolver_error))
167
169
  resolver.on(:close) { on_resolver_close(resolver) }
168
170
  resolver
169
- # rubocop: disable Layout/RescueEnsureAlignment
170
171
  rescue ArgumentError
171
172
  # this block is here because of an error which happens on CI from time to time
172
173
  warn "tried resolver: #{resolver_type}"
@@ -174,7 +175,6 @@ module HTTPX
174
175
  warn "new: #{resolver_type.method(:new).source_location}"
175
176
  raise
176
177
  end
177
- # rubocop: enable Layout/RescueEnsureAlignment
178
178
  end
179
179
  end
180
180
  end
@@ -33,11 +33,7 @@ module HTTPX
33
33
 
34
34
  USER_AGENT = "httpx.rb/#{VERSION}"
35
35
 
36
- attr_reader :verb, :uri, :headers, :body, :state
37
-
38
- attr_reader :options, :response
39
-
40
- def_delegator :@body, :<<
36
+ attr_reader :verb, :uri, :headers, :body, :state, :options, :response
41
37
 
42
38
  def_delegator :@body, :empty?
43
39
 
@@ -45,7 +41,7 @@ module HTTPX
45
41
 
46
42
  def initialize(verb, uri, options = {})
47
43
  @verb = verb.to_s.downcase.to_sym
48
- @uri = URI(uri.to_s)
44
+ @uri = Utils.uri(uri)
49
45
  @options = Options.new(options)
50
46
 
51
47
  raise(Error, "unknown method: #{verb}") unless METHODS.include?(@verb)
@@ -64,17 +60,15 @@ module HTTPX
64
60
  :w
65
61
  end
66
62
 
67
- # :nocov:
68
63
  if RUBY_VERSION < "2.2"
69
- # rubocop: disable Lint/UriEscapeUnescape:
64
+ URIParser = URI::DEFAULT_PARSER
65
+
70
66
  def initialize_with_escape(verb, uri, options = {})
71
- initialize_without_escape(verb, URI.escape(uri.to_s), options)
67
+ initialize_without_escape(verb, URIParser.escape(uri.to_s), options)
72
68
  end
73
69
  alias_method :initialize_without_escape, :initialize
74
70
  alias_method :initialize, :initialize_with_escape
75
- # rubocop: enable Lint/UriEscapeUnescape:
76
71
  end
77
- # :nocov:
78
72
 
79
73
  def merge_headers(h)
80
74
  @headers = @headers.merge(h)
@@ -180,19 +174,13 @@ module HTTPX
180
174
  return true if @body.nil?
181
175
  return false if chunked?
182
176
 
183
- bytesize.zero?
177
+ @body.bytesize.zero?
184
178
  end
185
179
 
186
180
  def bytesize
187
181
  return 0 if @body.nil?
188
182
 
189
- if @body.respond_to?(:bytesize)
190
- @body.bytesize
191
- elsif @body.respond_to?(:size)
192
- @body.size
193
- else
194
- raise Error, "cannot determine size of body: #{@body.inspect}"
195
- end
183
+ @body.bytesize
196
184
  end
197
185
 
198
186
  def stream(body)
@@ -94,7 +94,12 @@ module HTTPX
94
94
  def resolve(connection = @connections.first, hostname = nil)
95
95
  return if @building_connection
96
96
 
97
- hostname = hostname || @queries.key(connection) || connection.origin.host
97
+ hostname ||= @queries.key(connection)
98
+
99
+ if hostname.nil?
100
+ hostname = connection.origin.host
101
+ log { "resolver: resolve IDN #{connection.origin.non_ascii_hostname} as #{hostname}" } if connection.origin.non_ascii_hostname
102
+ end
98
103
  type = @_record_types[hostname].first
99
104
  log { "resolver: query #{type} for #{hostname}" }
100
105
  begin
@@ -206,7 +211,7 @@ module HTTPX
206
211
  case response.headers["content-type"]
207
212
  when "application/dns-json",
208
213
  "application/json",
209
- %r{^application\/x\-javascript} # because google...
214
+ %r{^application/x-javascript} # because google...
210
215
  payload = JSON.parse(response.to_s)
211
216
  payload["Answer"]
212
217
  when "application/dns-udpwireformat",
@@ -7,6 +7,7 @@ module HTTPX
7
7
  class Resolver::Native
8
8
  extend Forwardable
9
9
  include Resolver::ResolverMixin
10
+ using URIExtensions
10
11
 
11
12
  RESOLVE_TIMEOUT = 5
12
13
  RECORD_TYPES = {
@@ -168,7 +169,6 @@ module HTTPX
168
169
  siz = @io.read(wsize, @read_buffer)
169
170
  return unless siz && siz.positive?
170
171
 
171
- log { "resolver: READ: #{siz} bytes..." }
172
172
  parse(@read_buffer)
173
173
  return if @state == :closed
174
174
  end
@@ -181,7 +181,6 @@ module HTTPX
181
181
  siz = @io.write(@write_buffer)
182
182
  return unless siz && siz.positive?
183
183
 
184
- log { "resolver: WRITE: #{siz} bytes..." }
185
184
  return if @state == :closed
186
185
  end
187
186
  end
@@ -237,7 +236,12 @@ module HTTPX
237
236
  raise Error, "no URI to resolve" unless connection
238
237
  return unless @write_buffer.empty?
239
238
 
240
- hostname = hostname || @queries.key(connection) || connection.origin.host
239
+ hostname ||= @queries.key(connection)
240
+
241
+ if hostname.nil?
242
+ hostname = connection.origin.host
243
+ log { "resolver: resolve IDN #{connection.origin.non_ascii_hostname} as #{hostname}" } if connection.origin.non_ascii_hostname
244
+ end
241
245
  @queries[hostname] = connection
242
246
  type = @_record_types[hostname].first
243
247
  log { "resolver: query #{type} for #{hostname}" }