httpx 1.7.8 → 1.8.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/1_8_0.md +100 -0
  3. data/lib/httpx/adapters/datadog.rb +3 -1
  4. data/lib/httpx/connection/http1.rb +10 -1
  5. data/lib/httpx/connection/http2.rb +37 -4
  6. data/lib/httpx/connection.rb +76 -7
  7. data/lib/httpx/errors.rb +8 -1
  8. data/lib/httpx/io/tcp.rb +11 -1
  9. data/lib/httpx/options.rb +16 -4
  10. data/lib/httpx/parser/http1.rb +8 -2
  11. data/lib/httpx/plugins/auth.rb +52 -4
  12. data/lib/httpx/plugins/{response_cache → cache}/file_store.rb +1 -1
  13. data/lib/httpx/plugins/{response_cache → cache}/store.rb +1 -1
  14. data/lib/httpx/plugins/cache.rb +221 -0
  15. data/lib/httpx/plugins/fiber_concurrency.rb +50 -3
  16. data/lib/httpx/plugins/ntlm_v2_auth.rb +92 -0
  17. data/lib/httpx/plugins/oauth.rb +66 -14
  18. data/lib/httpx/plugins/proxy.rb +5 -0
  19. data/lib/httpx/plugins/response_cache.rb +26 -105
  20. data/lib/httpx/plugins/retries.rb +7 -5
  21. data/lib/httpx/plugins/server_sent_events.rb +158 -0
  22. data/lib/httpx/plugins/ssrf_filter.rb +16 -1
  23. data/lib/httpx/plugins/stream.rb +7 -3
  24. data/lib/httpx/plugins/tracing.rb +15 -4
  25. data/lib/httpx/request.rb +18 -1
  26. data/lib/httpx/resolver/cache/file.rb +56 -0
  27. data/lib/httpx/resolver/native.rb +14 -3
  28. data/lib/httpx/response/body.rb +4 -2
  29. data/lib/httpx/response.rb +9 -1
  30. data/lib/httpx/selector.rb +7 -1
  31. data/lib/httpx/version.rb +1 -1
  32. data/sig/chainable.rbs +3 -0
  33. data/sig/connection/http1.rbs +1 -1
  34. data/sig/connection/http2.rbs +1 -1
  35. data/sig/connection.rbs +11 -8
  36. data/sig/errors.rbs +9 -3
  37. data/sig/httpx.rbs +2 -0
  38. data/sig/io/tcp.rbs +2 -0
  39. data/sig/loggable.rbs +4 -0
  40. data/sig/options.rbs +25 -12
  41. data/sig/parser/http1.rbs +3 -1
  42. data/sig/plugins/auth/ntlm.rbs +1 -1
  43. data/sig/plugins/{response_cache → cache}/file_store.rbs +2 -2
  44. data/sig/plugins/{response_cache → cache}/store.rbs +2 -2
  45. data/sig/plugins/cache.rbs +69 -0
  46. data/sig/plugins/fiber_concurrency.rbs +4 -0
  47. data/sig/plugins/ntlm_v2_auth.rbs +36 -0
  48. data/sig/plugins/response_cache.rbs +13 -38
  49. data/sig/plugins/retries.rbs +5 -5
  50. data/sig/plugins/server_sent_events.rbs +45 -0
  51. data/sig/plugins/ssrf_filter.rbs +5 -1
  52. data/sig/plugins/stream.rbs +1 -1
  53. data/sig/plugins/stream_bidi.rbs +0 -2
  54. data/sig/plugins/webdav.rbs +1 -1
  55. data/sig/pool.rbs +2 -2
  56. data/sig/request.rbs +7 -3
  57. data/sig/resolver/cache/file.rbs +13 -0
  58. data/sig/resolver/entry.rbs +1 -1
  59. data/sig/resolver/https.rbs +3 -3
  60. data/sig/resolver/multi.rbs +1 -1
  61. data/sig/resolver/native.rbs +5 -5
  62. data/sig/resolver/resolver.rbs +1 -3
  63. data/sig/resolver/system.rbs +2 -2
  64. data/sig/resolver.rbs +3 -0
  65. data/sig/response.rbs +3 -0
  66. data/sig/selector.rbs +11 -8
  67. data/sig/timers.rbs +5 -5
  68. data/sig/transcoder/body.rbs +1 -1
  69. data/sig/transcoder/gzip.rbs +3 -2
  70. data/sig/transcoder/multipart.rbs +4 -1
  71. data/sig/transcoder/utils/deflater.rbs +2 -0
  72. data/sig/transcoder.rbs +2 -0
  73. data/sig/utils.rbs +1 -1
  74. metadata +17 -7
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTPX::Plugins
4
- module ResponseCache
4
+ module Cache
5
5
  # Implementation of a thread-safe in-memory cache store.
6
6
  class Store
7
7
  def initialize
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin adds support for caching and reusing responses
7
+ #
8
+ # https://gitlab.com/os85/httpx/wikis/Cache
9
+ #
10
+ module Cache
11
+ class << self
12
+ def load_dependencies(*)
13
+ require_relative "cache/store"
14
+ require_relative "cache/file_store"
15
+ end
16
+
17
+ def extra_options(options)
18
+ options.merge(
19
+ response_cache_store: :store,
20
+ )
21
+ end
22
+ end
23
+
24
+ # adds support for the following options:
25
+ #
26
+ # :cache_key :: callable which receives a request and returns the corresponding cache key as a string
27
+ # (to be used by the cache store when storing cached responses)
28
+ # :cacheable_request :: callable which receives a request and returns whether this request can use a previously cached response,
29
+ # or for which a freshly retrieved response can be cached.
30
+ # :cacheable_response :: callable which receives a request and a (freshly retrieved) response and returns whether the response
31
+ # can be cached.
32
+ # :valid_cached_response :: callable which receives a request and a (previously cached) response and returns whether the response
33
+ # can still be used / returned to the caller.
34
+ # :response_cache_store :: object where cached responses are fetch from or stored in; defaults to <tt>:store</tt> (in-memory
35
+ # cache), can be set to <tt>:file_store</tt> (file system cache store) as well, or any object which
36
+ # abides by the Cache Store Interface
37
+ #
38
+ # The Cache Store Interface requires implementation of the following methods:
39
+ #
40
+ # * +#get(request) -> response or nil+
41
+ # * +#set(request, response) -> void+
42
+ # * +#clear() -> void+)
43
+ #
44
+ module OptionsMethods
45
+ private
46
+
47
+ def option_cache_key(v)
48
+ raise TypeError, "`:cache_key` must be a callable" unless v.respond_to?(:call)
49
+
50
+ v
51
+ end
52
+
53
+ def option_cacheable_request(v)
54
+ raise TypeError, "`:cacheable_request` must be a callable" unless v.respond_to?(:call)
55
+
56
+ v
57
+ end
58
+
59
+ def option_cacheable_response(v)
60
+ raise TypeError, "`:cacheable_response` must be a callable" unless v.respond_to?(:call)
61
+
62
+ v
63
+ end
64
+
65
+ def option_valid_cached_response(v)
66
+ raise TypeError, "`:valid_cached_response` must be a callable" unless v.respond_to?(:call)
67
+
68
+ v
69
+ end
70
+
71
+ def option_response_cache_store(value)
72
+ case value
73
+ when :store
74
+ Store.new
75
+ when :file_store
76
+ FileStore.new
77
+ else
78
+ value
79
+ end
80
+ end
81
+ end
82
+
83
+ module InstanceMethods
84
+ # wipes out all cached responses from the cache store.
85
+ def clear_response_cache
86
+ @options.response_cache_store.clear
87
+ end
88
+
89
+ def build_request(*)
90
+ request = super
91
+ return request unless cacheable_request?(request)
92
+
93
+ prepare_cache(request)
94
+
95
+ request
96
+ end
97
+
98
+ private
99
+
100
+ def send_request(request, *)
101
+ return request if request.response
102
+
103
+ super
104
+ end
105
+
106
+ def fetch_response(request, *)
107
+ response = super
108
+
109
+ return unless response
110
+
111
+ if cacheable_request?(request) && cacheable_response?(request, response) && !response.cached?
112
+ log { "caching response for #{request.uri}..." }
113
+ request.options.response_cache_store.set(request, response)
114
+ end
115
+
116
+ response
117
+ end
118
+
119
+ # whether +request+ can use cached responses.
120
+ def cacheable_request?(request)
121
+ return false unless (call = request.options.cacheable_request)
122
+
123
+ call[request]
124
+ end
125
+
126
+ # whether the retrieved +response+ can be cached.
127
+ def cacheable_response?(request, response)
128
+ return false unless (call = request.options.cacheable_response)
129
+
130
+ call[request, response]
131
+ end
132
+
133
+ # whether the cached +cached_response+ is still valid for the current +request+
134
+ def valid_cached_response?(request, cached_response)
135
+ return false unless (call = request.options.valid_cached_response)
136
+
137
+ call[request, cached_response]
138
+ end
139
+
140
+ # will either assign a still-fresh cached response to +request+, or set up its HTTP
141
+ # cache invalidation headers in case it's not fresh anymore.
142
+ def prepare_cache(request)
143
+ cached_response = retrieve_cached_response(request)
144
+
145
+ return unless cached_response && valid_cached_response?(request, cached_response)
146
+
147
+ request.cached_response = nil
148
+
149
+ # if the cached response is still usable, we use it
150
+ cached_response.body.rewind
151
+ cached_response = cached_response.dup
152
+ cached_response.mark_as_cached!
153
+ request.response = cached_response
154
+ request.emit_response(cached_response)
155
+ end
156
+
157
+ # calls the cache store to retrieve the cached response for +request+. Caches it
158
+ # for convenience of subplugins in order to minimize overhead of retrieval (which may
159
+ # involve network).
160
+ def retrieve_cached_response(request)
161
+ request.cached_response ||= request.options.response_cache_store.get(request)
162
+ end
163
+ end
164
+
165
+ module RequestMethods
166
+ # points to a previously cached Response corresponding to this request.
167
+ attr_accessor :cached_response
168
+
169
+ def initialize(*)
170
+ super
171
+ @cached_response = nil
172
+ end
173
+
174
+ def merge_headers(*)
175
+ super
176
+ @response_cache_key = nil
177
+ end
178
+
179
+ # returns a unique cache key as a String identifying this request
180
+ def response_cache_key
181
+ return unless (call = @options.cache_key)
182
+
183
+ call[self]
184
+ end
185
+ end
186
+
187
+ module ResponseMethods
188
+ attr_writer :original_request
189
+
190
+ def initialize(*)
191
+ super
192
+ @cached = false
193
+ end
194
+
195
+ # a copy of the request this response was originally cached from
196
+ def original_request
197
+ @original_request || @request
198
+ end
199
+
200
+ # whether this Response was duplicated from a previously {RequestMethods#cached_response}.
201
+ def cached?
202
+ @cached
203
+ end
204
+
205
+ # sets this Response as being duplicated from a previously cached response.
206
+ def mark_as_cached!
207
+ @cached = true
208
+ end
209
+ end
210
+
211
+ module ResponseBodyMethods
212
+ def decode_chunk(chunk)
213
+ return chunk if @response.cached?
214
+
215
+ super
216
+ end
217
+ end
218
+ end
219
+ register_plugin :cache, Cache
220
+ end
221
+ end
@@ -76,12 +76,44 @@ module HTTPX
76
76
  super
77
77
  end
78
78
 
79
- def send(request)
80
- # DoH requests bypass the session, so context needs to be set here.
81
- request.set_context!
79
+ def on_io_error(e)
80
+ return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
81
+
82
+ # @fiber-switch-guard
83
+ # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
84
+ # socket may be used in. this check verifies that this is actually about this socket.
85
+ return unless to_io.closed?
86
+
87
+ if @state == :closing
88
+ # @fiber-switch-guard
89
+ # if the connection is reused across fibers, the socket may have been closed in the other fiber
90
+ # and switched here during the process, so continue what it was doing and transition to closed
91
+ # via #call.
92
+ call
93
+ elsif !backlog?
94
+ super
95
+ end
96
+ end
97
+
98
+ def on_connect_error(e)
99
+ return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
100
+
101
+ # @fiber-switch-guard
102
+ # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
103
+ # socket may be used in. this check verifies that this is actually about this socket.
104
+ return unless to_io.closed? && !backlog?
82
105
 
83
106
  super
84
107
  end
108
+
109
+ private
110
+
111
+ # checks whether the connection has any pending request (which the connection itself may
112
+ # have stored, or it may be somewhere in the parser).
113
+ def backlog?
114
+ @pending.any? ||
115
+ (@parser && (@parser.pending.any? || @parser.requests.any?))
116
+ end
85
117
  end
86
118
 
87
119
  module HTTP1Methods
@@ -168,6 +200,21 @@ module HTTPX
168
200
 
169
201
  super
170
202
  end
203
+
204
+ def disconnect
205
+ return unless @connections.all?(&:current_context?)
206
+
207
+ super
208
+ end
209
+
210
+ def on_io_error(e)
211
+ # TODO: return super if this is not stream clsed in another thread
212
+
213
+ log { "IO Erroring: #{e.message}, current:#{@name}, queries:#{@queries.size}" }
214
+ return unless @name
215
+
216
+ super
217
+ end
171
218
  end
172
219
 
173
220
  module ResolverSystemMethods
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ # https://gitlab.com/os85/httpx/wikis/Auth#ntlm-v2-auth
6
+ module NtlmV2Auth
7
+ class << self
8
+ def load_dependencies(klass)
9
+ require "rubyntlm"
10
+ klass.plugin(:auth)
11
+ end
12
+
13
+ def extra_options(options)
14
+ options.merge(max_concurrent_requests: 1)
15
+ end
16
+ end
17
+
18
+ class Authenticator
19
+ def initialize(user, password, domain: nil)
20
+ @user = user
21
+ @password = password
22
+ @domain = domain
23
+ end
24
+
25
+ def can_authenticate?(www_authenticate)
26
+ www_authenticate && /NTLM/i.match?(www_authenticate)
27
+ end
28
+
29
+ def negotiate
30
+ t1 = Net::NTLM::Message::Type1.new
31
+ t1.domain = @domain if @domain
32
+ "NTLM #{t1.encode64}"
33
+ end
34
+
35
+ def authenticate(_request, www_authenticate)
36
+ challenge_b64 = www_authenticate[/NTLM (.+)/i, 1]
37
+ t2 = Net::NTLM::Message.decode64(challenge_b64)
38
+ t3 = t2.response(
39
+ { user: @user, password: @password, domain: @domain },
40
+ ntlmv2: true
41
+ )
42
+ "NTLM #{t3.encode64}"
43
+ end
44
+ end
45
+
46
+ module OptionsMethods
47
+ private
48
+
49
+ def option_ntlm(value)
50
+ raise TypeError, ":ntlm must be a #{Authenticator}" unless value.is_a?(NtlmV2Auth::Authenticator)
51
+
52
+ value
53
+ end
54
+ end
55
+
56
+ module InstanceMethods
57
+ def ntlm_auth(user, password, domain = nil)
58
+ with(ntlm: Authenticator.new(user, password, domain: domain))
59
+ end
60
+
61
+ private
62
+
63
+ def send_requests(*requests)
64
+ requests.flat_map do |request|
65
+ ntlm = request.options.ntlm
66
+
67
+ if ntlm
68
+ request.authorize(ntlm.negotiate)
69
+ probe_response = wrap { super(request).first }
70
+
71
+ return probe_response unless probe_response.is_a?(Response)
72
+
73
+ if probe_response.status == 401 && ntlm.can_authenticate?(probe_response.headers["www-authenticate"])
74
+ request.transition(:idle)
75
+ request.unauthorize!
76
+ request.authorize(ntlm.authenticate(request,
77
+ probe_response.headers["www-authenticate"]).encode("utf-8"))
78
+ super(request)
79
+ else
80
+ probe_response
81
+ end
82
+ else
83
+ super(request)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ register_plugin :ntlm_v2_auth, NtlmV2Auth
91
+ end
92
+ end
@@ -12,6 +12,7 @@ module HTTPX
12
12
  module OAuth
13
13
  class << self
14
14
  def load_dependencies(klass)
15
+ require "monitor"
15
16
  require_relative "auth/basic"
16
17
  klass.plugin(:auth)
17
18
  end
@@ -33,8 +34,6 @@ module HTTPX
33
34
  # Implements the bulk of functionality and maintains the state associated with the
34
35
  # management of the the lifecycle of an OAuth session.
35
36
  class OAuthSession
36
- attr_reader :access_token, :refresh_token
37
-
38
37
  def initialize(
39
38
  issuer:,
40
39
  client_id:,
@@ -62,8 +61,8 @@ module HTTPX
62
61
  @refresh_token = refresh_token
63
62
  @token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
64
63
  @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
65
- @access_token = access_token
66
- @refresh_token = refresh_token
64
+ @expires_at = nil
65
+ @token_mon = Monitor.new
67
66
 
68
67
  unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
69
68
  raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
@@ -84,8 +83,24 @@ module HTTPX
84
83
  @token_endpoint_auth_method || "client_secret_basic"
85
84
  end
86
85
 
86
+ def expires_at
87
+ @token_mon.synchronize { @expires_at }
88
+ end
89
+
90
+ def access_token
91
+ @token_mon.synchronize do
92
+ if (expires_at = @expires_at) && expires_at < Time.now.to_i
93
+ reset!
94
+ end
95
+
96
+ @access_token
97
+ end
98
+ end
99
+
87
100
  def reset!
88
- @access_token = nil
101
+ @token_mon.synchronize do
102
+ @access_token = @expires_at = nil
103
+ end
89
104
  end
90
105
 
91
106
  # when not available, it uses the +http+ object to request new access and refresh tokens.
@@ -117,9 +132,11 @@ module HTTPX
117
132
  when "client_credentials"
118
133
  # do nothing
119
134
  when "refresh_token"
120
- raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless refresh_token
135
+ ref_token = refresh_token
121
136
 
122
- form_post["refresh_token"] = refresh_token
137
+ raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless ref_token
138
+
139
+ form_post["refresh_token"] = ref_token
123
140
  end
124
141
 
125
142
  # POST /token
@@ -138,8 +155,13 @@ module HTTPX
138
155
 
139
156
  payload = token_response.json
140
157
 
141
- @refresh_token = payload["refresh_token"] || @refresh_token
142
- @access_token = payload["access_token"]
158
+ @token_mon.synchronize do
159
+ @refresh_token = payload.fetch("refresh_token", @refresh_token)
160
+ if (expires_in = payload["expires_in"])
161
+ @expires_at = Time.now.to_i + Integer(expires_in)
162
+ end
163
+ @access_token = payload["access_token"]
164
+ end
143
165
  end
144
166
 
145
167
  # TODO: remove this after deprecating the `:oauth_session` option
@@ -164,17 +186,23 @@ module HTTPX
164
186
 
165
187
  private
166
188
 
189
+ def refresh_token
190
+ @token_mon.synchronize { @refresh_token }
191
+ end
192
+
167
193
  # uses +http+ to fetch for the oauth server metadata.
168
194
  def load(http)
169
195
  return if @grant_type && @scope
170
196
 
171
197
  metadata = http.skip_auth_header { http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json }
172
198
 
173
- @token_endpoint = metadata["token_endpoint"]
174
- @scope = metadata["scopes_supported"]
175
- @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
176
- @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
177
- SUPPORTED_AUTH_METHODS.include?(am)
199
+ @token_mon.synchronize do
200
+ @token_endpoint = metadata["token_endpoint"]
201
+ @scope = metadata["scopes_supported"]
202
+ @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
203
+ @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
204
+ SUPPORTED_AUTH_METHODS.include?(am)
205
+ end
178
206
  end
179
207
  nil
180
208
  end
@@ -239,6 +267,11 @@ module HTTPX
239
267
 
240
268
  @oauth_session.reset!
241
269
  @oauth_session.fetch_access_token(self)
270
+ if (expires_at = @oauth_session.expires_at)
271
+ @auth_header_value_mtx.synchronize do
272
+ @auth_header_expires_at = expires_at
273
+ end
274
+ end
242
275
  end
243
276
 
244
277
  # TODO: deprecate
@@ -249,9 +282,18 @@ module HTTPX
249
282
  other_session = dup # : instance
250
283
  oauth_session = other_session.oauth_session
251
284
  oauth_session.fetch_access_token(other_session)
285
+ if (expires_at = oauth_session.expires_at)
286
+ @auth_header_expires_at = expires_at
287
+ end
252
288
  other_session
253
289
  end
254
290
 
291
+ def reset_auth_header_value!
292
+ super.tap do
293
+ @oauth_session.reset if @oauth_session
294
+ end
295
+ end
296
+
255
297
  private
256
298
 
257
299
  def generate_auth_token
@@ -260,6 +302,16 @@ module HTTPX
260
302
  @oauth_session.fetch_access_token(self)
261
303
  end
262
304
 
305
+ def set_auth_header_expires_at(_)
306
+ return super unless @oauth_session
307
+
308
+ expires_at = @oauth_session.expires_at
309
+
310
+ return super unless expires_at
311
+
312
+ @auth_header_expires_at = expires_at
313
+ end
314
+
263
315
  def dynamic_auth_token?(_)
264
316
  @oauth_session
265
317
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module HTTPX
4
6
  class ProxyError < ConnectionError; end
5
7
 
@@ -65,6 +67,9 @@ module HTTPX
65
67
 
66
68
  return unless @scheme
67
69
 
70
+ @username = CGI.unescape(@username) if @username
71
+ @password = CGI.unescape(@password) if @password
72
+
68
73
  @authenticator = load_authenticator(@scheme, @username, @password, **extra)
69
74
  end
70
75