httpx 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +4 -0
- data/lib/httpx/buffer.rb +2 -0
- data/lib/httpx/chainable.rb +5 -5
- data/lib/httpx/channel/http2.rb +22 -3
- data/lib/httpx/channel.rb +74 -20
- data/lib/httpx/client.rb +33 -23
- data/lib/httpx/connection.rb +77 -9
- data/lib/httpx/errors.rb +4 -0
- data/lib/httpx/io/ssl.rb +19 -1
- data/lib/httpx/io/tcp.rb +25 -7
- data/lib/httpx/io/udp.rb +56 -0
- data/lib/httpx/io/unix.rb +4 -1
- data/lib/httpx/io.rb +2 -0
- data/lib/httpx/options.rb +3 -2
- data/lib/httpx/plugins/proxy.rb +14 -11
- data/lib/httpx/resolver/https.rb +181 -0
- data/lib/httpx/resolver/native.rb +251 -0
- data/lib/httpx/resolver/options.rb +25 -0
- data/lib/httpx/resolver/resolver_mixin.rb +61 -0
- data/lib/httpx/resolver/system.rb +34 -0
- data/lib/httpx/resolver.rb +103 -0
- data/lib/httpx/response.rb +8 -1
- data/lib/httpx/selector.rb +10 -4
- data/lib/httpx/timeout.rb +1 -1
- data/lib/httpx/version.rb +1 -1
- metadata +9 -2
@@ -0,0 +1,251 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
require "resolv"
|
5
|
+
|
6
|
+
module HTTPX
|
7
|
+
class Resolver::Native
|
8
|
+
extend Forwardable
|
9
|
+
include Resolver::ResolverMixin
|
10
|
+
|
11
|
+
RESOLVE_TIMEOUT = 5
|
12
|
+
RECORD_TYPES = {
|
13
|
+
"A" => Resolv::DNS::Resource::IN::A,
|
14
|
+
"AAAA" => Resolv::DNS::Resource::IN::AAAA,
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
DEFAULTS = if RUBY_VERSION < "2.2"
|
18
|
+
{
|
19
|
+
**Resolv::DNS::Config.default_config_hash,
|
20
|
+
packet_size: 512,
|
21
|
+
timeouts: RESOLVE_TIMEOUT,
|
22
|
+
record_types: RECORD_TYPES.keys,
|
23
|
+
}.freeze
|
24
|
+
else
|
25
|
+
{
|
26
|
+
nameserver: nil,
|
27
|
+
**Resolv::DNS::Config.default_config_hash,
|
28
|
+
packet_size: 512,
|
29
|
+
timeouts: RESOLVE_TIMEOUT,
|
30
|
+
record_types: RECORD_TYPES.keys,
|
31
|
+
}.freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
DNS_PORT = 53
|
35
|
+
|
36
|
+
def_delegator :@channels, :empty?
|
37
|
+
|
38
|
+
def initialize(_, options)
|
39
|
+
@options = Options.new(options)
|
40
|
+
@ns_index = 0
|
41
|
+
@resolver_options = Resolver::Options.new(DEFAULTS.merge(@options.resolver_options || {}))
|
42
|
+
@nameserver = @resolver_options.nameserver
|
43
|
+
@_timeouts = Array(@resolver_options.timeouts)
|
44
|
+
@timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
|
45
|
+
@_record_types = Hash.new { |types, host| types[host] = @resolver_options.record_types.dup }
|
46
|
+
@channels = []
|
47
|
+
@queries = {}
|
48
|
+
@read_buffer = Buffer.new(@resolver_options.packet_size)
|
49
|
+
@write_buffer = Buffer.new(@resolver_options.packet_size)
|
50
|
+
@state = :idle
|
51
|
+
end
|
52
|
+
|
53
|
+
def close
|
54
|
+
transition(:closed)
|
55
|
+
end
|
56
|
+
|
57
|
+
def closed?
|
58
|
+
@state == :closed
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_io
|
62
|
+
case @state
|
63
|
+
when :idle
|
64
|
+
transition(:open)
|
65
|
+
when :closed
|
66
|
+
transition(:idle)
|
67
|
+
transition(:open)
|
68
|
+
end
|
69
|
+
resolve if @queries.empty?
|
70
|
+
@io.to_io
|
71
|
+
end
|
72
|
+
|
73
|
+
def call
|
74
|
+
case @state
|
75
|
+
when :open
|
76
|
+
consume
|
77
|
+
end
|
78
|
+
nil
|
79
|
+
rescue Errno::EHOSTUNREACH => e
|
80
|
+
@ns_index += 1
|
81
|
+
if @ns_index < @nameserver.size
|
82
|
+
transition(:idle)
|
83
|
+
else
|
84
|
+
ex = ResolvError.new(e.message)
|
85
|
+
ex.set_backtrace(e.backtrace)
|
86
|
+
raise ex
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def interests
|
91
|
+
readable = !@read_buffer.full?
|
92
|
+
writable = !@write_buffer.empty?
|
93
|
+
if readable
|
94
|
+
writable ? :rw : :r
|
95
|
+
else
|
96
|
+
writable ? :w : :r
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def <<(channel)
|
101
|
+
return if early_resolve(channel)
|
102
|
+
if @nameserver.nil?
|
103
|
+
ex = ResolveError.new("Can't resolve #{channel.uri.host}")
|
104
|
+
ex.set_backtrace(caller)
|
105
|
+
emit(:error, channel, ex)
|
106
|
+
else
|
107
|
+
@channels << channel
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def timeout
|
112
|
+
@start_timeout = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
113
|
+
hosts = @queries.keys
|
114
|
+
@timeouts.values_at(*hosts).reject(&:empty?).map(&:first).min
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def consume
|
120
|
+
dread
|
121
|
+
do_retry
|
122
|
+
dwrite
|
123
|
+
end
|
124
|
+
|
125
|
+
def do_retry
|
126
|
+
return if @queries.empty?
|
127
|
+
loop_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_timeout
|
128
|
+
channels = []
|
129
|
+
queries = {}
|
130
|
+
while (query = @queries.shift)
|
131
|
+
h, channel = query
|
132
|
+
host = channel.uri.host
|
133
|
+
timeout = (@timeouts[host][0] -= loop_time)
|
134
|
+
unless timeout.negative?
|
135
|
+
queries[h] = channel
|
136
|
+
next
|
137
|
+
end
|
138
|
+
@timeouts[host].shift
|
139
|
+
if @timeouts[host].empty?
|
140
|
+
@timeouts.delete(host)
|
141
|
+
emit_resolve_error(channel, host)
|
142
|
+
return
|
143
|
+
else
|
144
|
+
channels << channel
|
145
|
+
log(label: "resolver: ") do
|
146
|
+
"timeout after #{prev_timeout}s, retry(#{timeouts.first}) #{host}..."
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
@queries = queries
|
151
|
+
channels.each { |ch| resolve(ch) }
|
152
|
+
end
|
153
|
+
|
154
|
+
def dread(wsize = @read_buffer.limit)
|
155
|
+
loop do
|
156
|
+
siz = @io.read(wsize, @read_buffer)
|
157
|
+
unless siz
|
158
|
+
emit(:close)
|
159
|
+
return
|
160
|
+
end
|
161
|
+
return if siz.zero?
|
162
|
+
log(label: "resolver: ") { "READ: #{siz} bytes..." }
|
163
|
+
parse(@read_buffer.to_s)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def dwrite
|
168
|
+
loop do
|
169
|
+
return if @write_buffer.empty?
|
170
|
+
siz = @io.write(@write_buffer)
|
171
|
+
unless siz
|
172
|
+
emit(:close)
|
173
|
+
return
|
174
|
+
end
|
175
|
+
log(label: "resolver: ") { "WRITE: #{siz} bytes..." }
|
176
|
+
return if siz.zero?
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def parse(buffer)
|
181
|
+
addresses = Resolver.decode_dns_answer(buffer)
|
182
|
+
if addresses.empty?
|
183
|
+
hostname, channel = @queries.first
|
184
|
+
if @_record_types[hostname].empty?
|
185
|
+
emit_resolve_error(channel, hostname)
|
186
|
+
return
|
187
|
+
end
|
188
|
+
else
|
189
|
+
address = addresses.first
|
190
|
+
channel = @queries.delete(address["name"])
|
191
|
+
return unless channel # probably a retried query for which there's an answer
|
192
|
+
if address.key?("alias") # CNAME
|
193
|
+
if early_resolve(channel, hostname: address["alias"])
|
194
|
+
@channels.delete(channel)
|
195
|
+
else
|
196
|
+
resolve(channel, address["alias"])
|
197
|
+
@queries.delete(address["name"])
|
198
|
+
return
|
199
|
+
end
|
200
|
+
else
|
201
|
+
@channels.delete(channel)
|
202
|
+
Resolver.cached_lookup_set(channel.uri.host, addresses)
|
203
|
+
emit_addresses(channel, addresses.map { |addr| addr["data"] })
|
204
|
+
end
|
205
|
+
end
|
206
|
+
return emit(:close) if @channels.empty?
|
207
|
+
resolve
|
208
|
+
end
|
209
|
+
|
210
|
+
def resolve(channel = @channels.first, hostname = nil)
|
211
|
+
raise Error, "no URI to resolve" unless channel
|
212
|
+
return unless @write_buffer.empty?
|
213
|
+
hostname = hostname || @queries.key(channel) || channel.uri.host
|
214
|
+
@queries[hostname] = channel
|
215
|
+
type = @_record_types[hostname].shift
|
216
|
+
log(label: "resolver: ") { "query #{type} for #{hostname}" }
|
217
|
+
@write_buffer << Resolver.encode_dns_query(hostname, type: RECORD_TYPES[type])
|
218
|
+
end
|
219
|
+
|
220
|
+
def build_socket
|
221
|
+
return if @io
|
222
|
+
ip, port = @nameserver[@ns_index]
|
223
|
+
port ||= DNS_PORT
|
224
|
+
uri = URI::Generic.build(scheme: "udp", port: port)
|
225
|
+
uri.hostname = ip
|
226
|
+
type = IO.registry(uri.scheme)
|
227
|
+
log(label: "resolver: ") { "server: #{uri}..." }
|
228
|
+
@io = type.new(uri, [IPAddr.new(ip)], @options)
|
229
|
+
end
|
230
|
+
|
231
|
+
def transition(nextstate)
|
232
|
+
case nextstate
|
233
|
+
when :idle
|
234
|
+
if @io
|
235
|
+
@io.close
|
236
|
+
@io = nil
|
237
|
+
end
|
238
|
+
@timeouts.clear
|
239
|
+
when :open
|
240
|
+
return unless @state == :idle
|
241
|
+
build_socket
|
242
|
+
@io.connect
|
243
|
+
return unless @io.connected?
|
244
|
+
when :closed
|
245
|
+
return unless @state == :open
|
246
|
+
@io.close if @io
|
247
|
+
end
|
248
|
+
@state = nextstate
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
class Resolver::Options
|
5
|
+
def initialize(options = {})
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def method_missing(m, *args, &block)
|
10
|
+
if @options.key?(m)
|
11
|
+
@options[m]
|
12
|
+
else
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def respond_to_missing?(m)
|
18
|
+
@options.key?(m) || super
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
@options
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "resolv"
|
4
|
+
require "ipaddr"
|
5
|
+
|
6
|
+
module HTTPX
|
7
|
+
module Resolver
|
8
|
+
module ResolverMixin
|
9
|
+
include Callbacks
|
10
|
+
include Loggable
|
11
|
+
|
12
|
+
CHECK_IF_IP = proc do |name|
|
13
|
+
begin
|
14
|
+
IPAddr.new(name)
|
15
|
+
true
|
16
|
+
rescue ArgumentError
|
17
|
+
false
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def uncache(channel)
|
22
|
+
hostname = hostname || @queries.key(channel) || channel.uri.host
|
23
|
+
Resolver.uncache(hostname)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def emit_addresses(channel, addresses)
|
29
|
+
addresses.map! do |address|
|
30
|
+
address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
|
31
|
+
end
|
32
|
+
log(label: "resolver: ") { "answer #{channel.uri.host}: #{addresses.inspect}" }
|
33
|
+
channel.addresses = addresses
|
34
|
+
emit(:resolve, channel, addresses)
|
35
|
+
end
|
36
|
+
|
37
|
+
def early_resolve(channel, hostname: channel.uri.host)
|
38
|
+
addresses = ip_resolve(hostname) || Resolver.cached_lookup(hostname) || system_resolve(hostname)
|
39
|
+
return unless addresses
|
40
|
+
emit_addresses(channel, addresses)
|
41
|
+
end
|
42
|
+
|
43
|
+
def ip_resolve(hostname)
|
44
|
+
[hostname] if CHECK_IF_IP[hostname]
|
45
|
+
end
|
46
|
+
|
47
|
+
def system_resolve(hostname)
|
48
|
+
@system_resolver ||= Resolv::Hosts.new
|
49
|
+
ips = @system_resolver.getaddresses(hostname)
|
50
|
+
return if ips.empty?
|
51
|
+
ips.map { |ip| IPAddr.new(ip) }
|
52
|
+
end
|
53
|
+
|
54
|
+
def emit_resolve_error(channel, hostname)
|
55
|
+
error = ResolveError.new("Can't resolve #{hostname}")
|
56
|
+
error.set_backtrace(caller)
|
57
|
+
emit(:error, channel, error)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
require "resolv"
|
5
|
+
|
6
|
+
module HTTPX
|
7
|
+
class Resolver::System
|
8
|
+
include Resolver::ResolverMixin
|
9
|
+
|
10
|
+
def initialize(_, options)
|
11
|
+
@options = Options.new(options)
|
12
|
+
roptions = @options.resolver_options
|
13
|
+
@state = :idle
|
14
|
+
@resolver = Resolv::DNS.new(roptions.nil? ? nil : roptions)
|
15
|
+
@resolver.timeouts = roptions[:timeouts] if roptions
|
16
|
+
end
|
17
|
+
|
18
|
+
def closed?
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
def empty?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
def <<(channel)
|
27
|
+
hostname = channel.uri.host
|
28
|
+
addresses = ip_resolve(hostname) || system_resolve(hostname) || @resolver.getaddresses(hostname)
|
29
|
+
addresses.empty? ? emit_resolve_error(channel, hostname) : emit_addresses(channel, addresses)
|
30
|
+
end
|
31
|
+
|
32
|
+
def uncache(*); end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Resolver
|
5
|
+
autoload :ResolverMixin, "httpx/resolver/resolver_mixin"
|
6
|
+
autoload :System, "httpx/resolver/system"
|
7
|
+
autoload :Native, "httpx/resolver/native"
|
8
|
+
autoload :HTTPS, "httpx/resolver/https"
|
9
|
+
|
10
|
+
extend Registry
|
11
|
+
|
12
|
+
register :system, :System
|
13
|
+
register :native, :Native
|
14
|
+
register :https, :HTTPS
|
15
|
+
|
16
|
+
@lookup_mutex = Mutex.new
|
17
|
+
@lookups = Hash.new { |h, k| h[k] = [] }
|
18
|
+
|
19
|
+
@identifier_mutex = Mutex.new
|
20
|
+
@identifier = 1
|
21
|
+
|
22
|
+
module_function
|
23
|
+
|
24
|
+
def cached_lookup(hostname)
|
25
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
26
|
+
@lookup_mutex.synchronize do
|
27
|
+
lookup(hostname, now)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def cached_lookup_set(hostname, entries)
|
32
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
33
|
+
entries.each do |entry|
|
34
|
+
entry["TTL"] += now
|
35
|
+
end
|
36
|
+
@lookup_mutex.synchronize do
|
37
|
+
@lookups[hostname] += entries
|
38
|
+
entries.each do |entry|
|
39
|
+
@lookups[entry["name"]] << entry if entry["name"] != hostname
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def uncache(hostname)
|
45
|
+
@lookup_mutex.synchronize do
|
46
|
+
@lookups.delete(hostname)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# do not use directly!
|
51
|
+
def lookup(hostname, ttl)
|
52
|
+
return unless @lookups.key?(hostname)
|
53
|
+
@lookups[hostname] = @lookups[hostname].select do |address|
|
54
|
+
address["TTL"] > ttl
|
55
|
+
end
|
56
|
+
ips = @lookups[hostname].flat_map do |address|
|
57
|
+
if address.key?("alias")
|
58
|
+
lookup(address["alias"], ttl)
|
59
|
+
else
|
60
|
+
address["data"]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
ips unless ips.empty?
|
64
|
+
end
|
65
|
+
|
66
|
+
def generate_id
|
67
|
+
@identifier_mutex.synchronize { @identifier = (@identifier + 1) & 0xFFFF }
|
68
|
+
end
|
69
|
+
|
70
|
+
def encode_dns_query(hostname, type: Resolv::DNS::Resource::IN::A)
|
71
|
+
Resolv::DNS::Message.new.tap do |query|
|
72
|
+
query.id = generate_id
|
73
|
+
query.rd = 1
|
74
|
+
query.add_question(hostname, type)
|
75
|
+
end.encode
|
76
|
+
end
|
77
|
+
|
78
|
+
def decode_dns_answer(payload)
|
79
|
+
message = Resolv::DNS::Message.decode(payload)
|
80
|
+
addresses = []
|
81
|
+
message.each_answer do |question, _, value|
|
82
|
+
case value
|
83
|
+
when Resolv::DNS::Resource::IN::CNAME
|
84
|
+
addresses << {
|
85
|
+
"name" => question.to_s,
|
86
|
+
"TTL" => value.ttl,
|
87
|
+
"alias" => value.name.to_s,
|
88
|
+
}
|
89
|
+
when Resolv::DNS::Resource::IN::A,
|
90
|
+
Resolv::DNS::Resource::IN::AAAA
|
91
|
+
addresses << {
|
92
|
+
"name" => question.to_s,
|
93
|
+
"TTL" => value.ttl,
|
94
|
+
"data" => value.address.to_s,
|
95
|
+
}
|
96
|
+
end
|
97
|
+
end
|
98
|
+
addresses
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
require "httpx/resolver/options"
|