Compare commits

...

8 Commits

Author SHA1 Message Date
HoneyryderChuck
b1c59c45eb fixing test docker image ruby 3.2 2023-01-22 01:42:10 +00:00
HoneyryderChuck
a218463c73 Merge branch 'fix-for-dns-candidate-queries' into 'master'
fix: native resolver only tries candidate names when server indicates

See merge request os85/httpx!236
2023-01-21 17:11:26 +00:00
HoneyryderChuck
af261006a3 fix: native resolver only tries candidate names when server indicates
domain not found

since httpx supports candidate calculations for dns queries, candidates
were always traversed when no answers were back. However, the DNS
message response contains a code set by the server, indicating whether
we should consider the domain existing **but** has no adderss, and
unexisting; candidates should only be queried in the latter.
2023-01-21 16:52:25 +00:00
HoneyryderChuck
b2cad74b03 Merge branch 'issue-223' into 'master'
fix happy eyeballs v2

Closes #223

See merge request os85/httpx!231
2023-01-21 16:41:35 +00:00
HoneyryderChuck
1700d2f4f2 Merge branch 'patch-for-ipv6-link-local-dns-address' into 'master'
fix: allow dns queries with ipv6 link-local address

See merge request os85/httpx!235
2023-01-21 16:41:19 +00:00
HoneyryderChuck
e26a74ab1d fix: allow dns queries with ipv6 link-local address
An issue was mis-identified for years, where it was thought that IPv6
addresses with the net interface suffix, attributed by the LAN, was
invalid; this was wrong, as that's an ip address with a zone identifier.
`ipaddr` has since fixed its support, which invalidated a
native resolver patch, and the error surfaced when building a URI
component for the UDP factory, because `uri` does not support zone
identifiers.

A patch was therefore added to the URI component, to allow setting up
the host while not validating it, as that was the shortest path to
fixing it right now.
2023-01-21 15:27:27 +00:00
HoneyryderChuck
133a6b3d4a simplify tcp connect error recovery loop 2023-01-21 00:51:03 +00:00
HoneyryderChuck
a5101870a5 fix for Happy Eyeballs v2 on failed connection
Implemementing the following fixes:

* connections are now marked by IP family of the IO;
* connection "mergeable" status dependent on matching origin when conn
  is already open;
* on a connection error, in case it happened while connection, an event
  is emitted, rather than handling it; if there is another connection
  for the same origin still doing the handshake, the error is ignored;
  if not, the error is handled;
* a new event, `:tcp_open`, is emitted when the tcp socket conn is
  established; this allows for concurrent handshakes to be promptly
  terminated, instead of being dependent on TLS handshake;
* connection cloning now happens early, as connection is set for
  resolving; this way, 2-way callbacks are set as early as possible;
2023-01-21 00:51:03 +00:00
16 changed files with 114 additions and 37 deletions

View File

@ -1,7 +1,7 @@
version: '3'
services:
httpx:
image: ruby:3.1
image: ruby:3.2
environment:
- HTTPBIN_COALESCING_HOST=another
- HTTPX_RESOLVER_URI=https://doh/dns-query

View File

@ -45,10 +45,12 @@ module HTTPX
def_delegator :@write_buffer, :empty?
attr_reader :io, :origin, :origins, :state, :pending, :options
attr_reader :type, :io, :origin, :origins, :state, :pending, :options
attr_writer :timers
attr_accessor :family
def initialize(type, uri, options)
@type = type
@origins = [uri.origin]
@ -76,13 +78,6 @@ module HTTPX
self.addresses = @options.addresses if @options.addresses
end
def clone_new_connection
new_conn = self.class.new(@type, @origin, @options)
once(:open, &new_conn.method(:reset))
new_conn.once(:open, &method(:close))
new_conn
end
# this is a semi-private method, to be used by the resolver
# to initiate the io object.
def addresses=(addrs)
@ -121,7 +116,10 @@ module HTTPX
return false unless connection.addresses
!(@io.addresses & connection.addresses).empty? && @options == connection.options
(
(open? && @origin == connection.origin) ||
!(@io.addresses & connection.addresses).empty?
) && @options == connection.options
end
# coalescable connections need to be mergeable!
@ -226,6 +224,14 @@ module HTTPX
@parser.close if @parser
end
# bypasses the state machine to force closing of connections still connecting.
# **only** used for Happy Eyeballs v2.
def force_reset
@state = :closing
transition(:closed)
emit(:close)
end
def reset
transition(:closing)
transition(:closed)
@ -527,11 +533,12 @@ module HTTPX
Errno::EINVAL,
Errno::ENETUNREACH,
Errno::EPIPE,
Errno::ENOENT => e
Errno::ENOENT,
SocketError => e
# connect errors, exit gracefully
error = ConnectionError.new(e.message)
error.set_backtrace(e.backtrace)
handle_error(error)
connecting? && callbacks(:connect_error).any? ? emit(:connect_error, error) : handle_error(error)
@state = :closed
emit(:close)
rescue TLSError => e
@ -550,6 +557,8 @@ module HTTPX
return if @state == :closed
@io.connect
emit(:tcp_open) if @io.state == :connected
return unless @io.connected?
@connected_at = Utils.now

View File

@ -160,6 +160,8 @@ module HTTPX
module URIExtensions
# uri 0.11 backport, ships with ruby 3.1
refine URI::Generic do
public :set_host
def non_ascii_hostname
@non_ascii_hostname
end

View File

@ -76,11 +76,7 @@ module HTTPX
Errno::EADDRNOTAVAIL,
Errno::EHOSTUNREACH,
SocketError => e
if @ip_index <= 0
error = ConnectionError.new(e.message)
error.set_backtrace(e.backtrace)
raise error
end
raise e if @ip_index <= 0
log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
@ip_index -= 1

View File

@ -72,7 +72,7 @@ module HTTPX
end
def init_connection(connection, _options)
resolve_connection(connection)
resolve_connection(connection) unless connection.family
connection.timers = @timers
connection.on(:open) do
@connected_connections += 1
@ -116,18 +116,55 @@ module HTTPX
# resolve a name (not the same as name being an IP, yet)
# 2. when the connection is initialized with an external already open IO.
#
connection.once(:connect_error, &connection.method(:handle_error))
on_resolver_connection(connection)
return
end
find_resolver_for(connection) do |resolver|
resolver << connection
resolver << try_clone_connection(connection, resolver.family)
next if resolver.empty?
select_connection(resolver)
end
end
def try_clone_connection(connection, family)
connection.family ||= family
if connection.family == family
return connection
end
new_connection = connection.class.new(connection.type, connection.origin, connection.options)
new_connection.family = family
connection.once(:tcp_open, &new_connection.method(:force_reset))
connection.once(:connect_error) do |err|
if new_connection.connecting?
new_connection.merge(connection)
else
connection.handle_error(err)
end
end
new_connection.once(:tcp_open) do
new_connection.merge(connection)
connection.force_reset
end
new_connection.once(:connect_error) do |err|
if connection.connecting?
# main connection has the requests
connection.merge(new_connection)
else
new_connection.handle_error(err)
end
end
init_connection(new_connection, connection.options)
new_connection
end
def on_resolver_connection(connection)
@connections << connection unless @connections.include?(connection)
found_connection = @connections.find do |ch|

View File

@ -108,7 +108,15 @@ module HTTPX
def decode_dns_answer(payload)
message = Resolv::DNS::Message.decode(payload)
# no domain was found
return if message.rcode == Resolv::DNS::RCode::NXDomain
addresses = []
# TODO: raise an "other dns OtherResolvError" type of error
return addresses if message.rcode != Resolv::DNS::RCode::NoError
message.each_answer do |question, _, value|
case value
when Resolv::DNS::Resource::IN::CNAME

View File

@ -136,9 +136,22 @@ module HTTPX
emit_resolve_error(connection, connection.origin.host, e)
return
end
if answers.nil? || answers.empty?
if answers.nil?
# Indicates no such domain was found.
host = @requests.delete(request)
connection = @queries.delete(host)
emit_resolve_error(connection) unless @queries.value?(connection)
elsif answers.empty?
# no address found, eliminate candidates
host = @requests.delete(request)
connection = @queries.delete(host)
# eliminate other candidates
@queries.delete_if { |_, conn| connection == conn }
emit_resolve_error(connection)
return

View File

@ -215,7 +215,8 @@ module HTTPX
raise ex
end
if addresses.nil? || addresses.empty?
if addresses.nil?
# Indicates no such domain was found.
hostname, connection = @queries.first
@queries.delete(hostname)
@timeouts.delete(hostname)
@ -224,6 +225,14 @@ module HTTPX
@connections.delete(connection)
raise NativeResolveError.new(connection, connection.origin.host)
end
elsif addresses.empty?
# no address found, eliminate candidates
_, connection = @queries.first
candidates = @queries.select { |_, conn| connection == conn }.keys
@queries.delete_if { |hs, _| candidates.include?(hs) }
@timeouts.delete_if { |hs, _| candidates.include?(hs) }
@connections.delete(connection)
raise NativeResolveError.new(connection, connection.origin.host)
else
address = addresses.first
name = address["name"]
@ -310,7 +319,9 @@ module HTTPX
ip, port = @nameserver[@ns_index]
port ||= DNS_PORT
uri = URI::Generic.build(scheme: "udp", port: port)
uri.hostname = ip
# uri.hostname = ip
# link-local IPv6 address may have a zone identifier, but URI does not support that yet.
uri.set_host(ip)
type = IO.registry(uri.scheme)
log { "resolver: server: #{uri}..." }
@io = type.new(uri, [IPAddr.new(ip)], @options)

View File

@ -73,11 +73,6 @@ module HTTPX
private
def emit_resolved_connection(connection, addresses)
if connection.io && connection.connecting? && @pool
new_connection = connection.clone_new_connection
@pool.init_connection(new_connection, connection.options)
connection = new_connection
end
connection.addresses = addresses
emit(:resolve, connection)

View File

@ -21,6 +21,7 @@ module HTTPX
BUFFER_SIZE: Integer
attr_reader type: io_type
attr_reader origin: URI::Generic
attr_reader origins: Array[String]
attr_reader state: Symbol
@ -28,7 +29,8 @@ module HTTPX
attr_reader options: Options
attr_writer timers: Timers
@type: io_type
attr_accessor family: Integer?
@window_size: Integer
@read_buffer: Buffer
@write_buffer: Buffer
@ -36,8 +38,6 @@ module HTTPX
@keep_alive_timeout: Numeric?
@total_timeout: Numeric?
def clone_new_connection: () -> instance
def addresses: () -> Array[ipaddr]?
def addresses=: (Array[ipaddr]) -> void

View File

@ -22,7 +22,9 @@ module HTTPX
private
def initialize: () -> untyped
def initialize: () -> void
def try_clone_connection: (Connection connection, Integer? family) -> Connection
def resolve_connection: (Connection) -> void

View File

@ -30,6 +30,6 @@ module HTTPX
def self?.encode_dns_query: (String hostname, ?type: dns_resource) -> String
def self?.decode_dns_answer: (String) -> Array[dns_result]
def self?.decode_dns_answer: (String) -> Array[dns_result]?
end
end

View File

@ -6,7 +6,8 @@ module HTTPX
DEFAULTS: Hash[Symbol, untyped]
FAMILY_TYPES: Hash[singleton(Resolv::DNS::Resource), String]
@family: ip_family
attr_reader family: ip_family
@options: Options
@requests: Hash[Request, String]
@connections: Array[Connection]
@ -33,7 +34,7 @@ module HTTPX
def build_request: (String hostname) -> Request
def decode_response_body: (Response) -> Array[dns_result]
def decode_response_body: (Response) -> Array[dns_result]?
end
end
end

View File

@ -7,7 +7,8 @@ module HTTPX
DEFAULTS: Hash[Symbol, untyped]
DNS_PORT: Integer
@family: ip_family
attr_reader family: ip_family
@options: Options
@ns_index: Integer
@nameserver: Array[String]?

View File

@ -6,7 +6,7 @@ module HTTPX
RECORD_TYPES: Hash[Integer, singleton(Resolv::DNS::Resource)]
attr_reader family: ip_family
attr_reader family: ip_family?
@record_type: singleton(Resolv::DNS::Resource)
@options: Options
@ -24,6 +24,8 @@ module HTTPX
private
def emit_resolved_connection: (Connection connection, Array[IPAddr] addresses) -> void
def initialize: (ip_family? family, options options) -> void
def early_resolve: (Connection connection, ?hostname: String) -> void

View File

@ -23,7 +23,7 @@ module Requests
return unless uri.start_with?("http://")
response = HTTPX.get(uri, addresses: [EHOSTUNREACH_HOST] * 2)
verify_error_response(response, Errno::EHOSTUNREACH)
verify_error_response(response, /No route to host/)
end
# TODO: reset this test once it's possible to test ETIMEDOUT again