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.
@@ -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"