|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | 3 | require "io/wait" |
| 4 | +require "timeout" |
4 | 5 |
|
5 | 6 | module HTTP |
6 | 7 | # Namespace for timeout handlers |
7 | 8 | module Timeout |
8 | 9 | # Base timeout handler with no timeout enforcement |
9 | 10 | 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 | + |
10 | 17 | # Timeout configuration options |
11 | 18 | # |
12 | 19 | # @example |
@@ -53,7 +60,7 @@ def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil, glob |
53 | 60 | # @api public |
54 | 61 | # @return [void] |
55 | 62 | def connect(socket_class, host, port, nodelay: false) |
56 | | - @socket = socket_class.open(host, port) |
| 63 | + @socket = open_socket(socket_class, host, port) |
57 | 64 | @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay |
58 | 65 | end |
59 | 66 |
|
@@ -164,6 +171,55 @@ def rescue_writable(timeout = write_timeout) |
164 | 171 | retry if @socket.to_io.wait_writable(timeout) |
165 | 172 | raise TimeoutError, "Write timed out after #{timeout} seconds" |
166 | 173 | 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 |
167 | 223 | end |
168 | 224 | end |
169 | 225 | end |
0 commit comments