httpx/test/support/requests/resolvers.rb
HoneyryderChuck 7ca5d9febb https resolver: close the http-2 connection on error
the connection was being kept open beyond the scope of the session
scope.

also, the connection wasn't deactivated after use, lingering in the
selector.
2025-11-04 13:15:29 +00:00

510 lines
20 KiB
Ruby

# frozen_string_literal: true
module Requests
module Resolvers
using HTTPX::URIExtensions
ResolverTimeoutPlugin = Module.new do
self::ResolverNativeMethods = Module.new do
def dread
@io.read(16_384, "".b)
super
end
end
self::ResolverHTTPSMethods = Module.new do
# this forces the resolver connection to timeout by setting it to read
# when it'll be ready to write requests.
def resolver_connection
super.tap do |conn|
def conn.interests
return super unless @state == :open
:r
end
end
end
end
self::ResolverSystemMethods = Module.new do
# this forces the system resolver to timeout by cleaning the pipe signal
# telling the main thread that there's a response.
def consume
sleep(0.5)
@pipe_read.read_nonblock(1, exception: false) # drain
super
end
end
end
{
native: { cache: false },
system: { cache: false },
https: { uri: ENV["HTTPX_RESOLVER_URI"], cache: false },
}.each do |resolver_type, options|
define_method :"test_resolver_#{resolver_type}_multiple_errors" do
2.times do |i|
session = HTTPX.plugin(SessionWithPool)
unknown_uri = "http://www.sfjewjfwigiewpgwwg-native-#{i}.com"
response = session.get(unknown_uri, resolver_class: resolver_type, resolver_options: options)
verify_error_response(response, HTTPX::ResolveError)
end
end
define_method :"test_resolver_#{resolver_type}_request" do
session = HTTPX.plugin(SessionWithPool)
uri = build_uri("/get")
response = session.head(uri, resolver_class: resolver_type, resolver_options: options)
verify_status(response, 200)
response.close
end
define_method :"test_resolver_#{resolver_type}_alias_request" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
# this google host will resolve to a CNAME
uri.host = "lh3.googleusercontent.com"
response = session.head(uri, resolver_class: resolver_type, resolver_options: options)
assert !response.is_a?(HTTPX::ErrorResponse), "response was an error (#{response})"
assert response.status < 500, "unexpected HTTP error (#{response})"
response.close
end
define_method :"test_resolver_#{resolver_type}_timeout" do
resolver_opts = options.merge(timeouts: [1, 2])
HTTPX.plugin(ResolverTimeoutPlugin).plugin(SessionWithPool).wrap do |session|
uri = build_uri("/get")
# before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
# after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
# total_time = after_time - before_time
verify_error_response(response, HTTPX::ResolveTimeoutError)
# assert_in_delta 2 + 1, total_time, 12, "request didn't take as expected to retry dns queries (#{total_time} secs)"
end
end
define_method :"test_resolver_#{resolver_type}_happy_eyeballs" do
skip if resolver_type == :system # still no way to pass the nameserver to getaddrinfo via ruby
uri = URI(build_uri("/get"))
start_test_servlet(TestDNSResolver) do |dns_server|
resolver_opts = options.merge(
nameserver: [dns_server.nameserver],
)
HTTPX.plugin(SessionWithPool)
.with(ip_families: [Socket::AF_INET6, Socket::AF_INET]) do |session|
response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
verify_status(response, 200)
conns = session.connections
if resolver_type == :https
assert conns.size == 3
resolver_uri = URI(resolver_opts[:uri])
conns.reject! { |c| c.origin.to_s == resolver_uri.origin }
else
assert conns.size == 2
end
assert(conns.all? { |c| c.origin.to_s == uri.origin })
assert(conns.one? { |c| c.family == Socket::AF_INET6 })
assert(conns.one? { |c| c.family == Socket::AF_INET })
assert(conns.one?(&:main_sibling))
end
end
end
case resolver_type
when :https
define_method :"test_resolver_#{resolver_type}_get_request" do
HTTPX.plugin(SessionWithPool).wrap do |http|
uri = build_uri("/get")
response = http.head(uri, resolver_class: resolver_type, resolver_options: options.merge(use_get: true))
verify_status(response, 200)
response.close
resolver_uri = URI(options[:uri])
resolver_conn = http.pool.connections.find { |c| c.origin.to_s == resolver_uri.origin }
assert resolver_conn, "https resolver connection should still be there"
assert resolver_conn.open?, "resolver connection should be kept around open"
end
end
define_method :"test_resolver_#{resolver_type}_unresolvable_servername" do
session = HTTPX.plugin(SessionWithPool)
uri = build_uri("/get")
response = session.head(uri, resolver_class: resolver_type, resolver_options: options.merge(uri: "https://unexisting-doh/dns-query"))
verify_error_response(response, HTTPX::ResolveError)
end
define_method :"test_resolver_#{resolver_type}_server_error" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
def build_request(_hostname)
@options.request_class.new("POST", @uri, @options)
end
end
response = session.head(uri, resolver_class: resolver_class, resolver_options: options)
verify_error_response(response, HTTPX::ResolveError)
end
define_method :"test_resolver_#{resolver_type}_decoding_error" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
def decode_response_body(_response)
[:decode_error, Resolv::DNS::DecodeError.new("smth")]
end
end
response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
verify_error_response(response, HTTPX::ResolveError)
end
define_method :"test_resolver_#{resolver_type}_encoding_error" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
def build_request(*)
raise Resolv::DNS::EncodeError, "ups"
end
end
assert_raises(Resolv::DNS::EncodeError) do
session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
end
end
define_method :"test_resolver_#{resolver_type}_dns_error" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
def decode_response_body(*)
[:dns_error, nil]
end
end
response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
verify_error_response(response, HTTPX::ResolveError)
assert session.pool.connections.empty?
end
define_method :"test_resolver_#{resolver_type}_no_answers" do
HTTPX.plugin(SessionWithPool).wrap do |http|
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
def parse_addresses(_, request)
super([], request)
end
end
response = http.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
verify_error_response(response, HTTPX::ResolveError)
resolver_uri = URI(options[:uri])
resolver_conn = http.pool.connections.find { |c| c.origin.to_s == resolver_uri.origin }
assert resolver_conn, "https resolver connection should still be there"
assert resolver_conn.state == :closed, "resolver connection should have closed after error"
end
end
when :native
define_method :"test_resolver_#{resolver_type}_tcp_request" do
tcp_socket = nil
resolver_class = Class.new(HTTPX::Resolver::Native) do
define_method :build_socket do
tcp_socket = super()
end
end
session = HTTPX.plugin(SessionWithPool)
uri = build_uri("/get")
response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(socket_type: :tcp))
verify_status(response, 200)
response.close
assert !tcp_socket.nil?
assert tcp_socket.is_a?(HTTPX::TCP)
end
define_method :"test_resolver_#{resolver_type}_same_relative_name" do
addresses = nil
resolver_class = Class.new(HTTPX::Resolver::Native) do
define_method :parse_addresses do |addrs|
addresses = addrs
super(addrs)
end
end
start_test_servlet(DNSSameRelativeName) do |slow_dns_server|
start_test_servlet(DNSSameRelativeName) do |not_so_slow_dns_server|
nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
resolver_opts = options.merge(nameserver: nameservers)
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
response = session.head(uri, resolver_class: resolver_class, resolver_options: resolver_opts)
verify_status(response, 200)
response.close
assert !addresses.nil?
addr = addresses.first
assert addr["name"] != uri.host
end
end
end
# this test mocks the case where there's no nameserver set to send the DNS queries to.
define_method :"test_resolver_#{resolver_type}_no_nameserver" do
session = HTTPX.plugin(SessionWithPool)
uri = build_uri("/get")
response = session.head(uri, resolver_class: resolver_type, resolver_options: options.merge(nameserver: nil))
verify_error_response(response, HTTPX::ResolveError)
end
define_method :"test_resolver_#{resolver_type}_slow_nameserver" do
start_test_servlet(SlowDNSServer, 6) do |slow_dns_server|
start_test_servlet(SlowDNSServer, 1) do |not_so_slow_dns_server|
nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
resolver_opts = options.merge(nameserver: nameservers, timeouts: [3])
HTTPX.plugin(SessionWithPool).wrap do |session|
uri = build_uri("/get")
response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
verify_status(response, 200)
resolver = session.resolver
assert resolver.instance_variable_get(:@ns_index) == 1
end
end
end
end
define_method :"test_resolver_#{resolver_type}_dns_error" do
start_test_servlet(DNSErrorServer) do |slow_dns_server|
start_test_servlet(DNSErrorServer) do |not_so_slow_dns_server|
nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
resolver_opts = options.merge(nameserver: nameservers)
HTTPX.plugin(SessionWithPool).wrap do |session|
uri = build_uri("/get")
response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
verify_error_response(response, /unknown DNS error/)
end
end
end
end
# this test mocks a DNS server invalid messages back
define_method :"test_resolver_#{resolver_type}_decoding_error" do
HTTPX.plugin(SessionWithPool).wrap do |session|
uri = URI(build_uri("/get"))
before_connections = nil
resolver_class = Class.new(HTTPX::Resolver::Native) do
attr_reader :connections
define_method :parse do |buffer|
before_connections = @connections.size
super(buffer[0..-2])
end
end
response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
verify_error_response(response, HTTPX::NativeResolveError)
assert session.resolvers.size == 1
resolver = session.resolvers.first
resolver = resolver.resolvers.first # because it's a multi
assert resolver.state == :closed
assert before_connections == 1, "resolver should have been resolving one connection"
assert resolver.connections.empty?, "resolver should not hold connections at this point anymore"
end
end
# this test mocks a DNS server breaking the socket with Errno::EHOSTUNREACH
define_method :"test_resolver_#{resolver_type}_unreachable" do
session = HTTPX.plugin(SessionWithPool)
uri = URI(build_uri("/get"))
resolver_class = Class.new(HTTPX::Resolver::Native) do
class << self
attr_accessor :attempts
end
self.attempts = 0
def dwrite
self.class.attempts += 1
raise Errno::EHOSTUNREACH, "host unreachable"
end
end
response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(nameserver: %w[127.0.0.1] * 3))
verify_error_response(response, HTTPX::ResolveError)
assert resolver_class.attempts == 3, "should have attempted to use all 3 nameservers"
end
define_method :"test_resolver_#{resolver_type}_max_udp_size_exceeded" do
uri = origin("1024.size.dns.netmeister.org")
session = HTTPX.plugin(SessionWithPool)
resolver_class = Class.new(HTTPX::Resolver::Native) do
@ios = []
class << self
attr_reader :ios
end
private
def build_socket
io = super
self.class.ios << io
io
end
end
response = session.head(uri, timeout: { connect_timeout: 2 }, resolver_class: resolver_class,
resolver_options: options.merge(nameserver: %w[166.84.7.99]))
verify_error_response(response, HTTPX::Error)
assert resolver_class.ios.any?(HTTPX::TCP), "resolver did not upgrade to tcp"
end
define_method :"test_resolver_#{resolver_type}_max_udp_size_exceeded_with_cname" do
uri = origin("1024.size.dns.netmeister.org")
session = HTTPX.plugin(SessionWithPool)
resolver_class = Class.new(HTTPX::Resolver::Native) do
@ios = []
class << self
attr_reader :ios
end
def build_socket
io = super
self.class.ios << io
io
end
def parse_addresses(addresses)
addr = addresses.first
return super unless addr["name"] == "1024.size.dns.netmeister.org"
# insert bogus CNAME
addresses.unshift(
{
"name" => "1024.size.dns.netmeister.org",
"TTL" => 10,
"alias" => ENV.fetch("HTTPBIN_HOST", "nghttp2.org/httpbin"),
}
)
super
end
end
response = session.head(uri, timeout: { connect_timeout: 2 }, resolver_class: resolver_class,
resolver_options: options.merge(nameserver: %w[166.84.7.99]))
verify_error_response(response, HTTPX::Error)
assert resolver_class.ios.any?(HTTPX::TCP), "resolver did not upgrade to tcp"
end
define_method :"test_resolver_#{resolver_type}_no_addresses" do
start_test_servlet(DNSNoAddress) do |slow_dns_server|
start_test_servlet(DNSNoAddress) do |not_so_slow_dns_server|
nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
resolver_opts = options.merge(nameserver: nameservers)
HTTPX.plugin(SessionWithPool).wrap do |session|
uri = build_uri("/get")
response = session.get(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
verify_error_response(response, /Can't resolve/)
end
end
end
end
define_method :"test_resolver_#{resolver_type}_ttl_expired" do
start_test_servlet(TestDNSResolver, ttl: 4) do |short_ttl_dns_server|
nameservers = [short_ttl_dns_server.nameserver]
resolver_opts = options.merge(nameserver: nameservers)
session = HTTPX.plugin(SessionWithPool)
2.times do
uri = URI(build_uri("/get"))
response = session.head(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
verify_status(response, 200)
response.close
end
# expire ttl
sleep 4
uri = URI(build_uri("/get"))
response = session.head(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
verify_status(response, 200)
response.close
num_answers = short_ttl_dns_server.answers
assert num_answers == 2, "should have only answered 2 times for DNS queries, instead is #{num_answers}"
end
end
define_method :"test_resolver_#{resolver_type}_candidate" do
uri = URI(build_uri("/get"))
only_to_candidate = Class.new(TestDNSResolver) do
define_method :dns_response do |query|
domain = extract_domain(query)
return unless domain == "#{uri.hostname}.local." # last condidate
super(query)
end
def resolve(domain, typevalue)
super(domain.delete_suffix(".local."), typevalue)
end
end
start_test_servlet(only_to_candidate) do |slow_dns_server|
dns_config = {
nameserver: [slow_dns_server.nameserver],
timeouts: [1, 2],
dots: 1,
search: "local",
}
resolver_opts = options.merge(dns_config)
HTTPX.plugin(SessionWithPool).wrap do |session|
response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
verify_status(response, 200)
assert session.resolvers.size == 1
resolver = session.resolvers.first
resolver = resolver.resolvers.first # because it's a multi
assert resolver.state == :closed
tries = resolver.tries
assert tries.keys.size == 2
assert tries.key?(uri.hostname)
assert tries[uri.hostname] == 2, "should have tried canonical 2 times"
assert tries.key?("#{uri.hostname}.local")
assert tries["#{uri.hostname}.local"] == 1, "should have succeeded search domain at the first time"
assert resolver.timeouts.empty?, "should have cleaned up all candidate timeouts"
end
end
end
end
end
end
end