httpx 0.20.2 → 0.20.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/doc/release_notes/0_20_3.md +6 -0
- data/doc/release_notes/0_20_4.md +17 -0
- data/doc/release_notes/0_20_5.md +3 -0
- data/lib/httpx/extensions.rb +28 -9
- data/lib/httpx/io/ssl.rb +6 -6
- data/lib/httpx/plugins/compression.rb +1 -1
- data/lib/httpx/plugins/response_cache/store.rb +39 -19
- data/lib/httpx/plugins/response_cache.rb +98 -8
- data/lib/httpx/pool.rb +1 -1
- data/lib/httpx/resolver/https.rb +1 -0
- data/lib/httpx/resolver/multi.rb +1 -1
- data/lib/httpx/resolver/native.rb +1 -1
- data/lib/httpx/resolver/resolver.rb +12 -2
- data/lib/httpx/response.rb +4 -1
- data/lib/httpx/transcoder/body.rb +1 -1
- data/lib/httpx/version.rb +1 -1
- data/sig/plugins/response_cache.rbs +24 -4
- data/sig/response.rbs +3 -0
- metadata +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 47766fc5b4fb9cb70e7b0feb8333656b987a25aea262c645c36de53e7cc3f464
|
4
|
+
data.tar.gz: 2f49417d21841b803d9f44b76c199785a54b7983c7712cf8a39b4837900e892b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f74fd61a16e738fbe668312e14d7c658af062f4f3dbcf65360b33892e27f31a524e7b675136de5e7693e327ba9a388c74c995e43b4e49a4593ec63fa385d07df
|
7
|
+
data.tar.gz: b04ae701b05663bd8c94d74416fd6a124d3a700e5860065d373f452383cc6f48a5569e8696dfac5a7f398824c163b3ddb62553a56c465022dc35ac34e627a479
|
@@ -0,0 +1,6 @@
|
|
1
|
+
# 0.20.3
|
2
|
+
|
3
|
+
## Bugfixes
|
4
|
+
|
5
|
+
* DoH resolver wasn't working for non-absolute (the large majority) of domains since v0.19.
|
6
|
+
* Allowing a single IP string to be passed to the resolver option `:nameserver` (just like the `resolv` library does), besides the already supported list of IPs.
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# 0.20.4
|
2
|
+
|
3
|
+
## Improvements
|
4
|
+
|
5
|
+
The `:response_cache` plugin is now more compliant with how the RFC 2616 defines which behaviour caches shall have:
|
6
|
+
|
7
|
+
* it caches only responses with one of the following status codes: 200, 203, 300, 301, 410.
|
8
|
+
* it discards cached responses which become stale.
|
9
|
+
* it supports "cache-control" header directives to decided when to cache, to store, what the response "age" is.
|
10
|
+
* it can cache more than one response for the same request, provided that the request presents different header values for the headers declared in the "vary" response header (previously, it was only caching the first response, and discarding the remainder).
|
11
|
+
|
12
|
+
|
13
|
+
|
14
|
+
## Bugfixes
|
15
|
+
|
16
|
+
* fixed DNS resolution bug which caused a loop when a failed connection attempt would cause a new DNS request to be triggered for the same domain, filling up and giving preference to the very IP which failed the attempt.
|
17
|
+
* response_cache: request verb is now taken into account, not causing HEAD/GET confusion for the same URL.
|
data/lib/httpx/extensions.rb
CHANGED
@@ -83,22 +83,41 @@ module HTTPX
|
|
83
83
|
end
|
84
84
|
|
85
85
|
module ArrayExtensions
|
86
|
-
|
86
|
+
module FilterMap
|
87
|
+
refine Array do
|
87
88
|
|
88
|
-
|
89
|
-
|
89
|
+
def filter_map
|
90
|
+
return to_enum(:filter_map) unless block_given?
|
90
91
|
|
91
|
-
|
92
|
-
|
93
|
-
|
92
|
+
each_with_object([]) do |item, res|
|
93
|
+
processed = yield(item)
|
94
|
+
res << processed if processed
|
95
|
+
end
|
94
96
|
end
|
95
97
|
end unless Array.method_defined?(:filter_map)
|
98
|
+
end
|
96
99
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
+
module Sum
|
101
|
+
refine Array do
|
102
|
+
def sum(accumulator = 0, &block)
|
103
|
+
values = block_given? ? map(&block) : self
|
104
|
+
values.inject(accumulator, :+)
|
105
|
+
end
|
100
106
|
end unless Array.method_defined?(:sum)
|
101
107
|
end
|
108
|
+
|
109
|
+
module Intersect
|
110
|
+
refine Array do
|
111
|
+
def intersect?(arr)
|
112
|
+
if size < arr.size
|
113
|
+
smaller = self
|
114
|
+
else
|
115
|
+
smaller, arr = arr, self
|
116
|
+
end
|
117
|
+
(arr & smaller).size > 0
|
118
|
+
end
|
119
|
+
end unless Array.method_defined?(:intersect?)
|
120
|
+
end
|
102
121
|
end
|
103
122
|
|
104
123
|
module IOExtensions
|
data/lib/httpx/io/ssl.rb
CHANGED
@@ -145,12 +145,12 @@ module HTTPX
|
|
145
145
|
"#{super}\n\n" \
|
146
146
|
"SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
|
147
147
|
"ALPN, server accepted to use #{protocol}\n" \
|
148
|
-
"Server certificate:\n" \
|
149
|
-
"
|
150
|
-
"
|
151
|
-
"
|
152
|
-
"
|
153
|
-
"
|
148
|
+
"Server certificate:\n " \
|
149
|
+
"subject: #{server_cert.subject}\n " \
|
150
|
+
"start date: #{server_cert.not_before}\n " \
|
151
|
+
"expire date: #{server_cert.not_after}\n " \
|
152
|
+
"issuer: #{server_cert.issuer}\n " \
|
153
|
+
"SSL certificate verify ok."
|
154
154
|
end
|
155
155
|
end
|
156
156
|
end
|
@@ -13,35 +13,38 @@ module HTTPX::Plugins
|
|
13
13
|
@store = {}
|
14
14
|
end
|
15
15
|
|
16
|
-
def lookup(
|
17
|
-
@store[
|
16
|
+
def lookup(request)
|
17
|
+
responses = @store[request.response_cache_key]
|
18
|
+
|
19
|
+
return unless responses
|
20
|
+
|
21
|
+
response = responses.find(&method(:match_by_vary?).curry(2)[request])
|
22
|
+
|
23
|
+
return unless response && response.fresh?
|
24
|
+
|
25
|
+
response
|
18
26
|
end
|
19
27
|
|
20
|
-
def cached?(
|
21
|
-
|
28
|
+
def cached?(request)
|
29
|
+
lookup(request)
|
22
30
|
end
|
23
31
|
|
24
|
-
def cache(
|
25
|
-
|
32
|
+
def cache(request, response)
|
33
|
+
return unless ResponseCache.cacheable_request?(request) && ResponseCache.cacheable_response?(response)
|
34
|
+
|
35
|
+
responses = (@store[request.response_cache_key] ||= [])
|
36
|
+
|
37
|
+
responses.reject!(&method(:match_by_vary?).curry(2)[request])
|
38
|
+
|
39
|
+
responses << response
|
26
40
|
end
|
27
41
|
|
28
42
|
def prepare(request)
|
29
|
-
cached_response =
|
43
|
+
cached_response = lookup(request)
|
30
44
|
|
31
45
|
return unless cached_response
|
32
46
|
|
33
|
-
|
34
|
-
|
35
|
-
if (vary = cached_response.headers["vary"])
|
36
|
-
if vary == "*"
|
37
|
-
return unless request.headers.same_headers?(original_request.headers)
|
38
|
-
else
|
39
|
-
return unless vary.split(/ *, */).all? do |cache_field|
|
40
|
-
cache_field.downcase!
|
41
|
-
!original_request.headers.key?(cache_field) || request.headers[cache_field] == original_request.headers[cache_field]
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
47
|
+
return unless match_by_vary?(request, cached_response)
|
45
48
|
|
46
49
|
if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
|
47
50
|
request.headers.add("if-modified-since", last_modified)
|
@@ -51,6 +54,23 @@ module HTTPX::Plugins
|
|
51
54
|
request.headers.add("if-none-match", etag)
|
52
55
|
end
|
53
56
|
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def match_by_vary?(request, response)
|
61
|
+
vary = response.vary
|
62
|
+
|
63
|
+
return true unless vary
|
64
|
+
|
65
|
+
original_request = response.instance_variable_get(:@request)
|
66
|
+
|
67
|
+
return request.headers.same_headers?(original_request.headers) if vary == %w[*]
|
68
|
+
|
69
|
+
vary.all? do |cache_field|
|
70
|
+
cache_field.downcase!
|
71
|
+
!original_request.headers.key?(cache_field) || request.headers[cache_field] == original_request.headers[cache_field]
|
72
|
+
end
|
73
|
+
end
|
54
74
|
end
|
55
75
|
end
|
56
76
|
end
|
@@ -9,7 +9,9 @@ module HTTPX
|
|
9
9
|
#
|
10
10
|
module ResponseCache
|
11
11
|
CACHEABLE_VERBS = %i[get head].freeze
|
12
|
+
CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
|
12
13
|
private_constant :CACHEABLE_VERBS
|
14
|
+
private_constant :CACHEABLE_STATUS_CODES
|
13
15
|
|
14
16
|
class << self
|
15
17
|
def load_dependencies(*)
|
@@ -17,14 +19,28 @@ module HTTPX
|
|
17
19
|
end
|
18
20
|
|
19
21
|
def cacheable_request?(request)
|
20
|
-
CACHEABLE_VERBS.include?(request.verb)
|
22
|
+
CACHEABLE_VERBS.include?(request.verb) &&
|
23
|
+
(
|
24
|
+
!request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
|
25
|
+
)
|
21
26
|
end
|
22
27
|
|
23
28
|
def cacheable_response?(response)
|
24
29
|
response.is_a?(Response) &&
|
25
|
-
|
30
|
+
(
|
31
|
+
response.cache_control.nil? ||
|
32
|
+
# TODO: !response.cache_control.include?("private") && is shared cache
|
33
|
+
!response.cache_control.include?("no-store")
|
34
|
+
) &&
|
35
|
+
CACHEABLE_STATUS_CODES.include?(response.status) &&
|
36
|
+
# RFC 2616 13.4 - A response received with a status code of 200, 203, 206, 300, 301 or
|
37
|
+
# 410 MAY be stored by a cache and used in reply to a subsequent
|
38
|
+
# request, subject to the expiration mechanism, unless a cache-control
|
39
|
+
# directive prohibits caching. However, a cache that does not support
|
40
|
+
# the Range and Content-Range headers MUST NOT cache 206 (Partial
|
41
|
+
# Content) responses.
|
26
42
|
response.status != 206 && (
|
27
|
-
response.headers.key?("etag") || response.headers.key?("last-modified-at")
|
43
|
+
response.headers.key?("etag") || response.headers.key?("last-modified-at") || response.fresh?
|
28
44
|
)
|
29
45
|
end
|
30
46
|
|
@@ -52,7 +68,7 @@ module HTTPX
|
|
52
68
|
|
53
69
|
def build_request(*)
|
54
70
|
request = super
|
55
|
-
return request unless ResponseCache.cacheable_request?(request) && @options.response_cache_store.cached?(request
|
71
|
+
return request unless ResponseCache.cacheable_request?(request) && @options.response_cache_store.cached?(request)
|
56
72
|
|
57
73
|
@options.response_cache_store.prepare(request)
|
58
74
|
|
@@ -62,25 +78,99 @@ module HTTPX
|
|
62
78
|
def fetch_response(request, *)
|
63
79
|
response = super
|
64
80
|
|
65
|
-
|
81
|
+
return unless response
|
82
|
+
|
83
|
+
if ResponseCache.cached_response?(response)
|
66
84
|
log { "returning cached response for #{request.uri}" }
|
67
|
-
cached_response = @options.response_cache_store.lookup(request
|
85
|
+
cached_response = @options.response_cache_store.lookup(request)
|
68
86
|
|
69
87
|
response.copy_from_cached(cached_response)
|
70
|
-
end
|
71
88
|
|
72
|
-
|
89
|
+
else
|
90
|
+
@options.response_cache_store.cache(request, response)
|
91
|
+
end
|
73
92
|
|
74
93
|
response
|
75
94
|
end
|
76
95
|
end
|
77
96
|
|
97
|
+
module RequestMethods
|
98
|
+
def response_cache_key
|
99
|
+
@response_cache_key ||= Digest::SHA1.hexdigest("httpx-response-cache-#{@verb}#{@uri}")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
78
103
|
module ResponseMethods
|
79
104
|
def copy_from_cached(other)
|
80
105
|
@body = other.body
|
81
106
|
|
82
107
|
@body.__send__(:rewind)
|
83
108
|
end
|
109
|
+
|
110
|
+
# A response is fresh if its age has not yet exceeded its freshness lifetime.
|
111
|
+
def fresh?
|
112
|
+
if cache_control
|
113
|
+
return false if cache_control.include?("no-cache")
|
114
|
+
|
115
|
+
# check age: max-age
|
116
|
+
max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
|
117
|
+
|
118
|
+
max_age ||= cache_control.find { |directive| directive.start_with?("max-age") }
|
119
|
+
|
120
|
+
max_age = max_age[/age=(\d+)/, 1] if max_age
|
121
|
+
|
122
|
+
max_age = max_age.to_i if max_age
|
123
|
+
|
124
|
+
return max_age > age if max_age
|
125
|
+
end
|
126
|
+
|
127
|
+
# check age: expires
|
128
|
+
if @headers.key?("expires")
|
129
|
+
begin
|
130
|
+
expires = Time.httpdate(@headers["expires"])
|
131
|
+
rescue ArgumentError
|
132
|
+
return true
|
133
|
+
end
|
134
|
+
|
135
|
+
return (expires - Time.now).to_i.positive?
|
136
|
+
end
|
137
|
+
|
138
|
+
true
|
139
|
+
end
|
140
|
+
|
141
|
+
def cache_control
|
142
|
+
return @cache_control if defined?(@cache_control)
|
143
|
+
|
144
|
+
@cache_control = begin
|
145
|
+
return unless @headers.key?("cache-control")
|
146
|
+
|
147
|
+
@headers["cache-control"].split(/ *, */)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def vary
|
152
|
+
return @vary if defined?(@vary)
|
153
|
+
|
154
|
+
@vary = begin
|
155
|
+
return unless @headers.key?("vary")
|
156
|
+
|
157
|
+
@headers["vary"].split(/ *, */)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def age
|
164
|
+
return @headers["age"].to_i if @headers.key?("age")
|
165
|
+
|
166
|
+
(Time.now - date).to_i
|
167
|
+
end
|
168
|
+
|
169
|
+
def date
|
170
|
+
@date ||= Time.httpdate(@headers["date"])
|
171
|
+
rescue NoMethodError, ArgumentError
|
172
|
+
Time.now.httpdate
|
173
|
+
end
|
84
174
|
end
|
85
175
|
end
|
86
176
|
register_plugin :response_cache, ResponseCache
|
data/lib/httpx/pool.rb
CHANGED
data/lib/httpx/resolver/https.rb
CHANGED
data/lib/httpx/resolver/multi.rb
CHANGED
@@ -45,7 +45,7 @@ module HTTPX
|
|
45
45
|
super
|
46
46
|
@ns_index = 0
|
47
47
|
@resolver_options = DEFAULTS.merge(@options.resolver_options)
|
48
|
-
@nameserver = @resolver_options[:nameserver]
|
48
|
+
@nameserver = Array(@resolver_options[:nameserver]) if @resolver_options[:nameserver]
|
49
49
|
@ndots = @resolver_options[:ndots]
|
50
50
|
@search = Array(@resolver_options[:search]).map { |srch| srch.scan(/[^.]+/) }
|
51
51
|
@_timeouts = Array(@resolver_options[:timeouts])
|
@@ -8,6 +8,8 @@ module HTTPX
|
|
8
8
|
include Callbacks
|
9
9
|
include Loggable
|
10
10
|
|
11
|
+
using ArrayExtensions::Intersect
|
12
|
+
|
11
13
|
RECORD_TYPES = {
|
12
14
|
Socket::AF_INET6 => Resolv::DNS::Resource::IN::AAAA,
|
13
15
|
Socket::AF_INET => Resolv::DNS::Resource::IN::A,
|
@@ -48,6 +50,10 @@ module HTTPX
|
|
48
50
|
addresses.map! do |address|
|
49
51
|
address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
|
50
52
|
end
|
53
|
+
|
54
|
+
# double emission check
|
55
|
+
return if connection.addresses && !addresses.intersect?(connection.addresses)
|
56
|
+
|
51
57
|
log { "resolver: answer #{connection.origin.host}: #{addresses.inspect}" }
|
52
58
|
if @pool && # if triggered by early resolve, pool may not be here yet
|
53
59
|
!connection.io &&
|
@@ -56,8 +62,12 @@ module HTTPX
|
|
56
62
|
addresses.first.to_s != connection.origin.host.to_s
|
57
63
|
log { "resolver: A response, applying resolution delay..." }
|
58
64
|
@pool.after(0.05) do
|
59
|
-
|
60
|
-
|
65
|
+
# double emission check
|
66
|
+
unless connection.addresses && addresses.intersect?(connection.addresses)
|
67
|
+
|
68
|
+
connection.addresses = addresses
|
69
|
+
emit(:resolve, connection)
|
70
|
+
end
|
61
71
|
end
|
62
72
|
else
|
63
73
|
connection.addresses = addresses
|
data/lib/httpx/response.rb
CHANGED
@@ -56,7 +56,7 @@ module HTTPX
|
|
56
56
|
|
57
57
|
# :nocov:
|
58
58
|
def inspect
|
59
|
-
"#<Response:#{object_id} "\
|
59
|
+
"#<Response:#{object_id} " \
|
60
60
|
"HTTP/#{version} " \
|
61
61
|
"@status=#{@status} " \
|
62
62
|
"@headers=#{@headers} " \
|
@@ -310,9 +310,12 @@ module HTTPX
|
|
310
310
|
|
311
311
|
class ErrorResponse
|
312
312
|
include Loggable
|
313
|
+
extend Forwardable
|
313
314
|
|
314
315
|
attr_reader :request, :error
|
315
316
|
|
317
|
+
def_delegator :@request, :uri
|
318
|
+
|
316
319
|
def initialize(request, error, options)
|
317
320
|
@request = request
|
318
321
|
@error = error
|
data/lib/httpx/version.rb
CHANGED
@@ -8,15 +8,19 @@ module HTTPX
|
|
8
8
|
def self?.cached_response?: (response response) -> bool
|
9
9
|
|
10
10
|
class Store
|
11
|
-
@store: Hash[
|
11
|
+
@store: Hash[String, Array[Response]]
|
12
12
|
|
13
|
-
def lookup: (
|
13
|
+
def lookup: (Request request) -> Response?
|
14
14
|
|
15
|
-
def cached?: (
|
15
|
+
def cached?: (Request request) -> boolish
|
16
16
|
|
17
|
-
def cache: (
|
17
|
+
def cache: (Request request, Response response) -> void
|
18
18
|
|
19
19
|
def prepare: (Request request) -> void
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def match_by_vary?: (Request request, Response response) -> bool
|
20
24
|
end
|
21
25
|
|
22
26
|
module InstanceMethods
|
@@ -25,8 +29,24 @@ module HTTPX
|
|
25
29
|
def clear_response_cache: () -> void
|
26
30
|
end
|
27
31
|
|
32
|
+
module RequestMethods
|
33
|
+
def response_cache_key: () -> String
|
34
|
+
end
|
35
|
+
|
28
36
|
module ResponseMethods
|
29
37
|
def copy_from_cached: (Response other) -> void
|
38
|
+
|
39
|
+
def fresh?: () -> bool
|
40
|
+
|
41
|
+
def cache_control: () -> Array[String]?
|
42
|
+
|
43
|
+
def vary: () -> Array[String]?
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def age: () -> Integer
|
48
|
+
|
49
|
+
def date: () -> Time
|
30
50
|
end
|
31
51
|
end
|
32
52
|
|
data/sig/response.rbs
CHANGED
@@ -96,6 +96,7 @@ module HTTPX
|
|
96
96
|
class ErrorResponse
|
97
97
|
include _Response
|
98
98
|
include Loggable
|
99
|
+
extend Forwardable
|
99
100
|
|
100
101
|
@options: Options
|
101
102
|
@error: Exception
|
@@ -104,6 +105,8 @@ module HTTPX
|
|
104
105
|
|
105
106
|
def status: () -> (Integer | _ToS)
|
106
107
|
|
108
|
+
def uri: () -> URI::Generic
|
109
|
+
|
107
110
|
private
|
108
111
|
|
109
112
|
def initialize: (Request, Exception, options) -> untyped
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: httpx
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.20.
|
4
|
+
version: 0.20.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tiago Cardoso
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-08-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: http-2-next
|
@@ -83,6 +83,9 @@ extra_rdoc_files:
|
|
83
83
|
- doc/release_notes/0_20_0.md
|
84
84
|
- doc/release_notes/0_20_1.md
|
85
85
|
- doc/release_notes/0_20_2.md
|
86
|
+
- doc/release_notes/0_20_3.md
|
87
|
+
- doc/release_notes/0_20_4.md
|
88
|
+
- doc/release_notes/0_20_5.md
|
86
89
|
- doc/release_notes/0_2_0.md
|
87
90
|
- doc/release_notes/0_2_1.md
|
88
91
|
- doc/release_notes/0_3_0.md
|
@@ -158,6 +161,9 @@ files:
|
|
158
161
|
- doc/release_notes/0_20_0.md
|
159
162
|
- doc/release_notes/0_20_1.md
|
160
163
|
- doc/release_notes/0_20_2.md
|
164
|
+
- doc/release_notes/0_20_3.md
|
165
|
+
- doc/release_notes/0_20_4.md
|
166
|
+
- doc/release_notes/0_20_5.md
|
161
167
|
- doc/release_notes/0_2_0.md
|
162
168
|
- doc/release_notes/0_2_1.md
|
163
169
|
- doc/release_notes/0_3_0.md
|
@@ -362,7 +368,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
362
368
|
- !ruby/object:Gem::Version
|
363
369
|
version: '0'
|
364
370
|
requirements: []
|
365
|
-
rubygems_version: 3.
|
371
|
+
rubygems_version: 3.3.7
|
366
372
|
signing_key:
|
367
373
|
specification_version: 4
|
368
374
|
summary: HTTPX, to the future, and beyond
|