Skip to content

Commit 61f7d0d

Browse files
committed
Enable Happy Eyeballs (RFC 8305)
Leverage Ruby 3.4's native Happy Eyeballs support in TCPSocket by passing connect_timeout as a keyword argument instead of relying solely on Timeout.timeout with Thread.raise, which can interfere with the Happy Eyeballs state machine. On Ruby < 3.4 or with custom socket classes, falls back to the existing Timeout.timeout behavior. Extract shared socket opening logic (open_socket, open_with_timeout, native_timeout?) into the Timeout::Null base class so all timeout handlers benefit. Closes #739.
1 parent 58aa513 commit 61f7d0d

9 files changed

Lines changed: 169 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
supporting MD5, SHA-256, MD5-sess, and SHA-256-sess algorithms with
5050
quality-of-protection negotiation. Works as a chainable feature:
5151
`HTTP.digest_auth(user: "admin", pass: "secret").get(url)` ([#448])
52+
- Happy Eyeballs (RFC 8305) support via Ruby 3.4's native `TCPSocket`
53+
implementation. Connection attempts now try multiple addresses (IPv6 and
54+
IPv4) concurrently, improving reliability on dual-stack networks. Connect
55+
timeouts are passed natively to `TCPSocket` instead of using
56+
`Timeout.timeout`, avoiding `Thread.raise` interference with the Happy
57+
Eyeballs state machine. ([#739])
5258
- `HTTP.base_uri` for setting a base URI that resolves relative request paths
5359
per RFC 3986. Supports chaining (`HTTP.base_uri("https://api.example.com/v1")
5460
.get("users")`), and integrates with `persistent` connections by deriving the

lib/http/connection.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ class Connection
4747
def initialize(req, options)
4848
init_state(options)
4949
connect_socket(req, options)
50+
rescue IO::TimeoutError => e
51+
close
52+
raise ConnectTimeoutError, e.message, e.backtrace
5053
rescue IOError, SocketError, SystemCallError => e
5154
raise ConnectionError, "failed to connect: #{e}", e.backtrace
5255
rescue TimeoutError

lib/http/timeout/global.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,8 @@ def reset_counter
5555
# @return [void]
5656
def connect(socket_class, host, port, nodelay: false)
5757
reset_timer
58-
::Timeout.timeout(effective_timeout(@connect_timeout), ConnectTimeoutError) do
59-
@socket = socket_class.open(host, port)
60-
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
61-
end
58+
@socket = open_socket(socket_class, host, port, connect_timeout: effective_timeout(@connect_timeout))
59+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
6260

6361
log_time
6462
end

lib/http/timeout/null.rb

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
# frozen_string_literal: true
22

33
require "io/wait"
4+
require "timeout"
45

56
module HTTP
67
# Namespace for timeout handlers
78
module Timeout
89
# Base timeout handler with no timeout enforcement
910
class Null
11+
# Whether TCPSocket natively supports connect_timeout and
12+
# Happy Eyeballs (RFC 8305). Available in Ruby 3.4+.
13+
#
14+
# @api private
15+
NATIVE_CONNECT_TIMEOUT = RUBY_VERSION >= "3.4"
16+
1017
# Timeout configuration options
1118
#
1219
# @example
@@ -53,7 +60,7 @@ def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil, glob
5360
# @api public
5461
# @return [void]
5562
def connect(socket_class, host, port, nodelay: false)
56-
@socket = socket_class.open(host, port)
63+
@socket = open_socket(socket_class, host, port)
5764
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
5865
end
5966

@@ -164,6 +171,55 @@ def rescue_writable(timeout = write_timeout)
164171
retry if @socket.to_io.wait_writable(timeout)
165172
raise TimeoutError, "Write timed out after #{timeout} seconds"
166173
end
174+
175+
# Opens a TCP socket, using native connect_timeout when available
176+
#
177+
# On Ruby 3.4+ with TCPSocket, passes connect_timeout natively to
178+
# enable proper Happy Eyeballs (RFC 8305) support. Falls back to
179+
# Timeout.timeout on older Rubies or with custom socket classes.
180+
#
181+
# @param [Class] socket_class socket class to create
182+
# @param [String] host remote hostname
183+
# @param [Integer] port remote port
184+
# @param [Numeric, nil] connect_timeout timeout in seconds
185+
# @return [Object] the connected socket
186+
# @api private
187+
def open_socket(socket_class, host, port, connect_timeout: nil)
188+
if connect_timeout
189+
::Timeout.timeout(connect_timeout, ConnectTimeoutError) do
190+
open_with_timeout(socket_class, host, port, connect_timeout)
191+
end
192+
else
193+
socket_class.open(host, port)
194+
end
195+
rescue IO::TimeoutError
196+
raise ConnectTimeoutError, "Connect timed out after #{connect_timeout} seconds"
197+
end
198+
199+
# Opens a socket, passing connect_timeout natively when supported
200+
#
201+
# @param [Class] socket_class socket class to create
202+
# @param [String] host remote hostname
203+
# @param [Integer] port remote port
204+
# @param [Numeric] connect_timeout timeout in seconds
205+
# @return [Object] the connected socket
206+
# @api private
207+
def open_with_timeout(socket_class, host, port, connect_timeout)
208+
if native_timeout?(socket_class)
209+
socket_class.open(host, port, connect_timeout: connect_timeout)
210+
else
211+
socket_class.open(host, port)
212+
end
213+
end
214+
215+
# Whether the socket class supports native connect_timeout
216+
#
217+
# @param [Class] socket_class socket class to check
218+
# @return [Boolean]
219+
# @api private
220+
def native_timeout?(socket_class)
221+
NATIVE_CONNECT_TIMEOUT && socket_class.is_a?(Class) && socket_class <= TCPSocket
222+
end
167223
end
168224
end
169225
end

lib/http/timeout/per_operation.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,8 @@ def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil)
102102
# @api public
103103
# @return [void]
104104
def connect(socket_class, host, port, nodelay: false)
105-
::Timeout.timeout(@connect_timeout, ConnectTimeoutError) do
106-
@socket = socket_class.open(host, port)
107-
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
108-
end
105+
@socket = open_socket(socket_class, host, port, connect_timeout: @connect_timeout)
106+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
109107
end
110108

111109
# Starts an SSL connection with connect timeout

sig/deps.rbs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ module IO::WaitWritable
9191
end
9292

9393
class IO
94+
# Missing from rbs core (available since Ruby 3.2)
95+
class TimeoutError < IOError
96+
end
97+
9498
class EAGAINWaitReadable < Errno::EAGAIN
9599
include IO::WaitReadable
96100
end

sig/http.rbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,8 @@ module HTTP
11641164

11651165
module Timeout
11661166
class Null
1167+
NATIVE_CONNECT_TIMEOUT: bool
1168+
11671169
attr_reader options: Hash[Symbol, Numeric]
11681170
attr_reader socket: untyped
11691171

@@ -1183,6 +1185,9 @@ module HTTP
11831185
def write_timeout: () -> Numeric?
11841186
def rescue_readable: (?Numeric? timeout) { () -> untyped } -> untyped
11851187
def rescue_writable: (?Numeric? timeout) { () -> untyped } -> untyped
1188+
def open_socket: (untyped socket_class, String host, Integer port, ?connect_timeout: Numeric?) -> untyped
1189+
def open_with_timeout: (untyped socket_class, String host, Integer port, Numeric connect_timeout) -> untyped
1190+
def native_timeout?: (untyped socket_class) -> bool
11861191
end
11871192

11881193
class PerOperation < Null

test/http/connection_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@
3939
HTTP::Connection.new(req, tls_opts)
4040
end
4141
end
42+
43+
it "converts IO::TimeoutError to ConnectTimeoutError" do
44+
io_timeout_socket = fake(
45+
connect: ->(*) { raise IO::TimeoutError, "Connect timed out!" },
46+
close: nil,
47+
closed?: false
48+
)
49+
io_timeout_class = fake(new: io_timeout_socket)
50+
io_opts = HTTP::Options.new(timeout_class: io_timeout_class)
51+
52+
err = assert_raises(HTTP::ConnectTimeoutError) do
53+
HTTP::Connection.new(req, io_opts)
54+
end
55+
assert_equal "Connect timed out!", err.message
56+
end
4257
end
4358

4459
describe "#read_headers!" do

test/http/timeout/null_test.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,79 @@
155155
end
156156
end
157157
end
158+
159+
describe "NATIVE_CONNECT_TIMEOUT" do
160+
it "is true on Ruby 3.4+" do
161+
assert_equal RUBY_VERSION >= "3.4", HTTP::Timeout::Null::NATIVE_CONNECT_TIMEOUT
162+
end
163+
end
164+
165+
describe "#open_socket (private)" do
166+
it "opens a socket without timeout" do
167+
tcp_socket = fake(closed?: false)
168+
socket_class = fake(open: tcp_socket)
169+
170+
result = timeout.send(:open_socket, socket_class, "example.com", 80)
171+
172+
assert_same tcp_socket, result
173+
end
174+
175+
it "passes connect_timeout natively when native timeout is supported" do
176+
received_args = nil
177+
stub_open = lambda do |*args, **kwargs|
178+
received_args = [args, kwargs]
179+
fake(closed?: false)
180+
end
181+
182+
timeout.stub(:native_timeout?, true) do
183+
TCPSocket.stub(:open, stub_open) do
184+
timeout.send(:open_socket, TCPSocket, "127.0.0.1", 1, connect_timeout: 5)
185+
end
186+
end
187+
188+
assert_equal [["127.0.0.1", 1], { connect_timeout: 5 }], received_args
189+
end
190+
191+
it "does not pass connect_timeout to non-TCPSocket classes" do
192+
received_args = nil
193+
tcp_socket = fake(closed?: false)
194+
socket_class = fake(open: proc { |*args|
195+
received_args = args
196+
tcp_socket
197+
})
198+
199+
timeout.send(:open_socket, socket_class, "example.com", 80, connect_timeout: 5)
200+
201+
assert_equal ["example.com", 80], received_args
202+
end
203+
204+
it "converts IO::TimeoutError to ConnectTimeoutError" do
205+
socket_class = fake(open: proc { |*| raise IO::TimeoutError, "Connect timed out!" })
206+
207+
err = assert_raises(HTTP::ConnectTimeoutError) do
208+
timeout.send(:open_socket, socket_class, "example.com", 80, connect_timeout: 5)
209+
end
210+
assert_match(/Connect timed out/, err.message)
211+
end
212+
end
213+
214+
describe "#native_timeout? (private)" do
215+
if RUBY_VERSION >= "3.4"
216+
it "returns true for TCPSocket on Ruby 3.4+" do
217+
assert timeout.send(:native_timeout?, TCPSocket)
218+
end
219+
else
220+
it "returns false for TCPSocket on Ruby < 3.4" do
221+
refute timeout.send(:native_timeout?, TCPSocket)
222+
end
223+
end
224+
225+
it "returns false for non-TCPSocket classes" do
226+
refute timeout.send(:native_timeout?, OpenSSL::SSL::SSLSocket)
227+
end
228+
229+
it "returns false for non-class objects" do
230+
refute timeout.send(:native_timeout?, fake(open: nil))
231+
end
232+
end
158233
end

0 commit comments

Comments
 (0)