Merge branch 'fix-ttl-expired'

This commit is contained in:
HoneyryderChuck 2025-09-04 13:57:41 +01:00
commit b1be71bb7f
11 changed files with 65 additions and 21 deletions

View File

@ -49,8 +49,8 @@ module HTTPX
def initialize(uri, options) def initialize(uri, options)
@current_session = @current_selector = @current_session = @current_selector =
@parser = @sibling = @coalesced_connection = @parser = @sibling = @coalesced_connection =
@io = @ssl_session = @timeout = @family = @io = @ssl_session = @timeout =
@connected_at = @response_received_at = nil @connected_at = @response_received_at = nil
@exhausted = @cloned = @main_sibling = false @exhausted = @cloned = @main_sibling = false

View File

@ -59,8 +59,14 @@ module HTTPX
# eliminates expired entries and returns whether there are still any left. # eliminates expired entries and returns whether there are still any left.
def addresses? def addresses?
prev_addr_size = @addresses.size
@addresses.delete_if(&:expired?) @addresses.delete_if(&:expired?)
unless (decr = prev_addr_size - @addresses.size).zero?
@ip_index = @addresses.size - decr
end
@addresses.any? @addresses.any?
end end

View File

@ -97,7 +97,7 @@ module HTTPX
end end
end end
request.on(:response) do |res| request.on(:response) do |res|
emit_or_callback_error(:response_completed, request, res) emit_or_callback_error(:response_completed, request, res) if res.is_a?(Response)
end end
end end

View File

@ -63,10 +63,6 @@ module HTTPX
end end
def cached_lookup_set(hostname, family, entries) def cached_lookup_set(hostname, family, entries)
now = Utils.now
entries.each do |entry|
entry["TTL"] += now
end
lookup_synchronize do |lookups| lookup_synchronize do |lookups|
case family case family
when Socket::AF_INET6 when Socket::AF_INET6
@ -145,19 +141,20 @@ module HTTPX
addresses = [] addresses = []
now = Utils.now
message.each_answer do |question, _, value| message.each_answer do |question, _, value|
case value case value
when Resolv::DNS::Resource::IN::CNAME when Resolv::DNS::Resource::IN::CNAME
addresses << { addresses << {
"name" => question.to_s, "name" => question.to_s,
"TTL" => value.ttl, "TTL" => (now + value.ttl),
"alias" => value.name.to_s, "alias" => value.name.to_s,
} }
when Resolv::DNS::Resource::IN::A, when Resolv::DNS::Resource::IN::A,
Resolv::DNS::Resource::IN::AAAA Resolv::DNS::Resource::IN::AAAA
addresses << { addresses << {
"name" => question.to_s, "name" => question.to_s,
"TTL" => value.ttl, "TTL" => (now + value.ttl),
"data" => value.address.to_s, "data" => value.address.to_s,
} }
end end

View File

@ -204,7 +204,7 @@ module HTTPX
@queries.delete_if { |_, conn| connection == conn } @queries.delete_if { |_, conn| connection == conn }
Resolver.cached_lookup_set(hostname, @family, addresses) if @resolver_options[:cache] Resolver.cached_lookup_set(hostname, @family, addresses) if @resolver_options[:cache]
catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |addr| addr["data"] }) } catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |a| Resolver::Entry.new(a["data"], a["TTL"]) }) }
end end
end end
return if @connections.empty? return if @connections.empty?

View File

@ -375,7 +375,9 @@ module HTTPX
@timeouts.delete(connection.peer.host) @timeouts.delete(connection.peer.host)
@connections.delete(connection) @connections.delete(connection)
Resolver.cached_lookup_set(connection.peer.host, @family, addresses) if @resolver_options[:cache] Resolver.cached_lookup_set(connection.peer.host, @family, addresses) if @resolver_options[:cache]
catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |addr| addr["data"] }) } catch(:coalesced) do
emit_addresses(connection, @family, addresses.map { |a| Resolver::Entry.new(a["data"], a["TTL"]) })
end
end end
end end
close_or_resolve close_or_resolve

View File

@ -71,7 +71,8 @@ module HTTPX
addresses.map! { |address| address.is_a?(Resolver::Entry) ? address : Resolver::Entry.new(address) } addresses.map! { |address| address.is_a?(Resolver::Entry) ? address : Resolver::Entry.new(address) }
# double emission check, but allow early resolution to work # double emission check, but allow early resolution to work
return if !early_resolve && connection.addresses && !addresses.intersect?(connection.addresses) conn_addrs = connection.addresses
return if !early_resolve && conn_addrs && (!conn_addrs.empty? && !addresses.intersect?(!conn_addrs))
log do log do
"resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \ "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \

View File

@ -198,7 +198,12 @@ module HTTPX
end end
when :closing, :closed when :closing, :closed
connection.idling connection.idling
select_connection(connection, selector) if connection.addresses?
select_connection(connection, selector)
else
# if addresses expired, resolve again
resolve_connection(connection, selector)
end
else else
pin_connection(connection, selector) pin_connection(connection, selector)
end end

View File

@ -6,28 +6,32 @@ class ResolverTest < Minitest::Test
include HTTPX include HTTPX
def test_cached_lookup def test_cached_lookup
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
assert_ips nil, Resolver.cached_lookup("test.com") assert_ips nil, Resolver.cached_lookup("test.com")
dns_entry = { "data" => "::2", "TTL" => 2, "name" => "test.com" } dns_entry = { "data" => "::2", "TTL" => now + 2, "name" => "test.com" }
Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [dns_entry]) Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [dns_entry])
assert_ips ["::2"], Resolver.cached_lookup("test.com") assert_ips ["::2"], Resolver.cached_lookup("test.com")
sleep 2 sleep 2
assert_ips nil, Resolver.cached_lookup("test.com") assert_ips nil, Resolver.cached_lookup("test.com")
alias_entry = { "alias" => "test.com", "TTL" => 2, "name" => "foo.com" }
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
dns_entry = { "data" => "::2", "TTL" => now + 2, "name" => "test.com" }
alias_entry = { "alias" => "test.com", "TTL" => now + 2, "name" => "foo.com" }
Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [dns_entry]) Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [dns_entry])
Resolver.cached_lookup_set("foo.com", Socket::AF_INET6, [alias_entry]) Resolver.cached_lookup_set("foo.com", Socket::AF_INET6, [alias_entry])
assert_ips ["::2"], Resolver.cached_lookup("foo.com") assert_ips ["::2"], Resolver.cached_lookup("foo.com")
Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [{ "data" => "::3", "TTL" => 2, "name" => "test.com" }]) Resolver.cached_lookup_set("test.com", Socket::AF_INET6, [{ "data" => "::3", "TTL" => now + 2, "name" => "test.com" }])
assert_ips %w[::2 ::3], Resolver.cached_lookup("test.com") assert_ips %w[::2 ::3], Resolver.cached_lookup("test.com")
Resolver.cached_lookup_set("test.com", Socket::AF_INET, [{ "data" => "127.0.0.2", "TTL" => 2, "name" => "test.com" }]) Resolver.cached_lookup_set("test.com", Socket::AF_INET, [{ "data" => "127.0.0.2", "TTL" => now + 2, "name" => "test.com" }])
assert_ips %w[127.0.0.2 ::2 ::3], Resolver.cached_lookup("test.com") assert_ips %w[127.0.0.2 ::2 ::3], Resolver.cached_lookup("test.com")
Resolver.cached_lookup_set("test2.com", Socket::AF_INET6, [{ "data" => "::4", "TTL" => 2, "name" => "test3.com" }]) Resolver.cached_lookup_set("test2.com", Socket::AF_INET6, [{ "data" => "::4", "TTL" => now + 2, "name" => "test3.com" }])
assert_ips %w[::4], Resolver.cached_lookup("test2.com") assert_ips %w[::4], Resolver.cached_lookup("test2.com")
assert_ips %w[::4], Resolver.cached_lookup("test3.com") assert_ips %w[::4], Resolver.cached_lookup("test3.com")
Resolver.cached_lookup_set("test2.com", Socket::AF_INET, [{ "data" => "127.0.0.3", "TTL" => 2, "name" => "test3.com" }]) Resolver.cached_lookup_set("test2.com", Socket::AF_INET, [{ "data" => "127.0.0.3", "TTL" => now + 2, "name" => "test3.com" }])
assert_ips %w[127.0.0.3 ::4], Resolver.cached_lookup("test2.com") assert_ips %w[127.0.0.3 ::4], Resolver.cached_lookup("test2.com")
assert_ips %w[127.0.0.3 ::4], Resolver.cached_lookup("test3.com") assert_ips %w[127.0.0.3 ::4], Resolver.cached_lookup("test3.com")
end end

View File

@ -304,6 +304,34 @@ module Requests
end end
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
end end
end end
end end

View File

@ -184,12 +184,13 @@ end
class TestDNSResolver class TestDNSResolver
attr_reader :queries, :answers attr_reader :queries, :answers
def initialize(port = next_available_port, socket_type = :udp) def initialize(port = next_available_port, socket_type = :udp, ttl: 120)
@port = port @port = port
@can_log = ENV.key?("HTTPX_DEBUG") @can_log = ENV.key?("HTTPX_DEBUG")
@queries = 0 @queries = 0
@answers = 0 @answers = 0
@socket_type = socket_type @socket_type = socket_type
@ttl = ttl
end end
def nameserver def nameserver
@ -264,7 +265,7 @@ class TestDNSResolver
section << "\x00\x01".b section << "\x00\x01".b
# TTL in seconds # TTL in seconds
section << [120].pack("N").b section << [@ttl].pack("N").b
# Calculate RDATA - we need its length in advance # Calculate RDATA - we need its length in advance
rdata = if cname rdata = if cname