Overview
Happy Eyeballs v2 implements RFC 8305 to optimize connection establishment in dual-stack networks supporting both IPv4 and IPv6. Ruby's implementation races IPv6 and IPv4 connection attempts with intelligent delays to minimize connection latency while preferring IPv6 when available.
The algorithm operates through Ruby's Socket
class and related networking APIs, automatically handling address resolution ordering, connection racing, and fallback mechanisms. When establishing TCP connections, Ruby attempts IPv6 first, then starts IPv4 connections after a configurable delay if IPv6 hasn't completed.
require 'socket'
# Happy Eyeballs v2 activates automatically for dual-stack connections
socket = Socket.tcp("example.com", 80, connect_timeout: 5)
# => Races IPv6 and IPv4 connections internally
The implementation maintains separate connection state for each address family, managing timeouts, error conditions, and connection selection. Ruby prioritizes successful IPv6 connections but falls back to IPv4 when IPv6 fails or takes too long.
# Socket.tcp uses Happy Eyeballs v2 internally
TCPSocket.open("dual-stack-server.com", 443) do |sock|
puts sock.addr # Shows which IP version was selected
end
Key classes involved include Socket
, TCPSocket
, Addrinfo
, and the internal Socket::HappyEyeballsV2
module. The algorithm handles DNS resolution results containing both AAAA (IPv6) and A (IPv4) records, sorting addresses by preference and managing connection attempts.
# Access connection details after establishment
socket = TCPSocket.new("example.org", 80)
local_addr = socket.local_address
remote_addr = socket.remote_address
puts "Connected via #{remote_addr.ip_address} (#{remote_addr.afamily})"
Basic Usage
Happy Eyeballs v2 activates transparently when Ruby establishes TCP connections to hostnames with both IPv4 and IPv6 addresses. The algorithm requires no explicit activation - Ruby handles the complexity internally.
# Standard connection automatically uses Happy Eyeballs v2
require 'net/http'
http = Net::HTTP.new('httpbin.org', 80)
http.open_timeout = 10
response = http.get('/')
# Connection racing happened transparently
Connection timeouts control the overall operation duration. Happy Eyeballs v2 respects these timeouts while managing internal racing delays:
require 'socket'
begin
# 3-second total timeout includes racing delays
socket = Socket.tcp('slow-server.example.com', 80, connect_timeout: 3)
puts "Connected successfully"
rescue Errno::ETIMEDOUT => e
puts "Connection timed out: #{e.message}"
ensure
socket&.close
end
For applications requiring connection details, inspect the resulting socket's address information:
socket = TCPSocket.new('github.com', 443)
# Examine the selected connection
local = socket.local_address
remote = socket.remote_address
puts "Local: #{local.ip_address}:#{local.ip_port} (#{local.afamily})"
puts "Remote: #{remote.ip_address}:#{remote.ip_port} (#{remote.afamily})"
# Typical output shows IPv6 was preferred:
# Local: 2001:db8::1:234 (AF_INET6)
# Remote: 2606:50c0:8000::154:443 (AF_INET6)
The algorithm works with various Ruby networking APIs that internally use Socket.tcp
:
require 'net/http'
require 'uri'
# HTTP libraries benefit automatically
uri = URI('https://www.ruby-lang.org')
response = Net::HTTP.get_response(uri)
puts "Status: #{response.code}"
# WebSocket libraries also benefit
require 'faye/websocket'
ws = Faye::WebSocket::Client.new('wss://echo.websocket.org/')
When working with servers that have connectivity issues on one protocol, Happy Eyeballs v2 provides automatic fallback:
# Server with IPv6 connectivity problems
begin
start_time = Time.now
socket = Socket.tcp('ipv6-broken.example.com', 80, connect_timeout: 5)
elapsed = Time.now - start_time
puts "Connected in #{elapsed.round(3)}s"
puts "Used: #{socket.remote_address.afamily}"
# Likely shows AF_INET (IPv4) after IPv6 failed
rescue => e
puts "Connection failed: #{e.class} - #{e.message}"
end
Thread Safety & Concurrency
Happy Eyeballs v2 handles internal concurrency safely, but applications using multiple threads for connections must manage socket objects appropriately. Each connection attempt creates separate racing threads internally that Ruby manages and cleans up automatically.
require 'socket'
require 'thread'
# Safe: Multiple threads making separate connections
threads = []
results = Queue.new
5.times do |i|
threads << Thread.new(i) do |thread_id|
begin
socket = Socket.tcp('httpbin.org', 80, connect_timeout: 5)
addr = socket.remote_address
results << { thread: thread_id, addr: addr.ip_address, family: addr.afamily }
socket.close
rescue => e
results << { thread: thread_id, error: e.message }
end
end
end
threads.each(&:join)
# Collect results
5.times do
result = results.pop
if result[:error]
puts "Thread #{result[:thread]}: Error - #{result[:error]}"
else
puts "Thread #{result[:thread]}: #{result[:addr]} (#{result[:family]})"
end
end
Socket objects themselves are not thread-safe for simultaneous read/write operations. However, the Happy Eyeballs v2 connection establishment phase completes before returning the socket, ensuring the returned object is ready for use:
# Dangerous: Sharing socket across threads without synchronization
socket = Socket.tcp('example.com', 80)
# Safe approach: Use mutex for shared socket access
mutex = Mutex.new
reader_thread = Thread.new do
mutex.synchronize do
data = socket.recv(1024)
puts "Received: #{data}"
end
end
writer_thread = Thread.new do
mutex.synchronize do
socket.write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
end
end
[reader_thread, writer_thread].each(&:join)
socket.close
Connection pools benefit from Happy Eyeballs v2 when establishing multiple connections to the same dual-stack host:
require 'socket'
require 'thread'
class ConnectionPool
def initialize(host, port, pool_size = 5)
@host = host
@port = port
@pool = Queue.new
@mutex = Mutex.new
pool_size.times do
@pool << create_connection
end
end
def with_connection
connection = @pool.pop
begin
yield connection
ensure
@pool << connection if connection && !connection.closed?
end
end
private
def create_connection
@mutex.synchronize do
Socket.tcp(@host, @port, connect_timeout: 3)
end
end
end
# Usage with automatic Happy Eyeballs v2 benefits
pool = ConnectionPool.new('api.example.com', 443)
# Multiple threads can safely use the pool
10.times do |i|
Thread.new(i) do |request_id|
pool.with_connection do |socket|
socket.write("GET /api/data/#{request_id} HTTP/1.1\r\n")
socket.write("Host: api.example.com\r\n\r\n")
response = socket.recv(1024)
puts "Request #{request_id}: #{response.split("\r\n").first}"
end
end
end
Applications using async I/O libraries should verify that their chosen library preserves Happy Eyeballs v2 behavior:
require 'socket'
require 'fiber'
# Fiber-based async connection example
def async_connect(host, port)
Fiber.new do
begin
# Happy Eyeballs v2 works within fibers
socket = Socket.tcp(host, port, connect_timeout: 5)
puts "Fiber connected to #{socket.remote_address.ip_address}"
socket
rescue => e
puts "Fiber connection failed: #{e.message}"
nil
end
end
end
# Create multiple async connections
fibers = %w[example.com github.com ruby-lang.org].map do |host|
async_connect(host, 80)
end
# Resume all fibers
sockets = fibers.map(&:resume).compact
puts "Established #{sockets.length} connections"
sockets.each(&:close)
Error Handling & Debugging
Happy Eyeballs v2 generates specific error patterns depending on connection failure modes. Understanding these patterns helps diagnose networking issues and implement appropriate retry strategies.
When both IPv4 and IPv6 connections fail, Ruby raises the error from the last attempted connection:
require 'socket'
begin
socket = Socket.tcp('nonexistent-host.invalid', 80, connect_timeout: 3)
rescue SocketError => e
puts "DNS resolution failed: #{e.message}"
# Typical: "getaddrinfo: Name or service not known"
rescue Errno::ETIMEDOUT => e
puts "Connection timeout: #{e.message}"
rescue Errno::ECONNREFUSED => e
puts "Connection refused: #{e.message}"
rescue Errno::EHOSTUNREACH => e
puts "Host unreachable: #{e.message}"
rescue Errno::ENETUNREACH => e
puts "Network unreachable: #{e.message}"
end
To diagnose which address family succeeded or failed, capture connection attempts with detailed error handling:
def detailed_connect(hostname, port, timeout: 5)
start_time = Time.now
begin
socket = Socket.tcp(hostname, port, connect_timeout: timeout)
elapsed = Time.now - start_time
remote = socket.remote_address
{
success: true,
duration: elapsed,
ip: remote.ip_address,
family: remote.afamily,
socket: socket
}
rescue => e
elapsed = Time.now - start_time
{
success: false,
duration: elapsed,
error: e.class.name,
message: e.message
}
end
end
# Test with various scenarios
hosts = [
'google.com', # Should work with both IPv4/IPv6
'ipv6-test.com', # May prefer IPv6
'ipv4-only.example', # Hypothetical IPv4-only host
'unreachable.invalid' # Should fail
]
hosts.each do |host|
puts "Testing #{host}:"
result = detailed_connect(host, 80)
if result[:success]
puts " ✓ Connected in #{result[:duration].round(3)}s"
puts " ✓ Address: #{result[:ip]} (#{result[:family]})"
result[:socket].close
else
puts " ✗ Failed after #{result[:duration].round(3)}s"
puts " ✗ Error: #{result[:error]} - #{result[:message]}"
end
puts
end
For debugging connection preferences and timing, implement a connection tracer that captures Happy Eyeballs v2 behavior:
class ConnectionTracer
def self.trace_connection(hostname, port, timeout: 10)
resolver_start = Time.now
# Get address information like Happy Eyeballs v2 does
addrs = Addrinfo.getaddrinfo(hostname, port, nil, :STREAM)
resolver_time = Time.now - resolver_start
ipv6_addrs = addrs.select { |a| a.afamily == Socket::AF_INET6 }
ipv4_addrs = addrs.select { |a| a.afamily == Socket::AF_INET }
puts "DNS Resolution (#{resolver_time.round(3)}s):"
puts " IPv6 addresses: #{ipv6_addrs.length}"
puts " IPv4 addresses: #{ipv4_addrs.length}"
ipv6_addrs.each { |a| puts " #{a.ip_address}" }
ipv4_addrs.each { |a| puts " #{a.ip_address}" }
# Attempt connection and see what Happy Eyeballs v2 chooses
connect_start = Time.now
begin
socket = Socket.tcp(hostname, port, connect_timeout: timeout)
connect_time = Time.now - connect_start
selected = socket.remote_address
puts "Connection Success (#{connect_time.round(3)}s):"
puts " Selected: #{selected.ip_address} (#{selected.afamily})"
puts " Total time: #{(resolver_time + connect_time).round(3)}s"
socket.close
true
rescue => e
connect_time = Time.now - connect_start
puts "Connection Failed (#{connect_time.round(3)}s):"
puts " Error: #{e.class} - #{e.message}"
puts " Total time: #{(resolver_time + connect_time).round(3)}s"
false
end
end
end
# Example usage
ConnectionTracer.trace_connection('github.com', 443)
Applications requiring retry logic should implement exponential backoff while preserving Happy Eyeballs v2 benefits:
def resilient_connect(hostname, port, max_retries: 3, base_delay: 1.0)
retry_count = 0
begin
Socket.tcp(hostname, port, connect_timeout: 5)
rescue => e
retry_count += 1
if retry_count <= max_retries
delay = base_delay * (2 ** (retry_count - 1))
puts "Connection failed (attempt #{retry_count}/#{max_retries}): #{e.message}"
puts "Retrying in #{delay}s..."
sleep delay
retry
else
puts "All connection attempts failed"
raise
end
end
end
# Usage preserves Happy Eyeballs v2 across retries
begin
socket = resilient_connect('flaky-server.example.com', 80)
puts "Connected successfully"
socket.close
rescue => e
puts "Final failure: #{e.message}"
end
Production Patterns
Production applications benefit from Happy Eyeballs v2 through improved connection reliability and reduced latency. Web servers, API clients, and database connections automatically gain dual-stack optimization without code changes.
HTTP client libraries inherit Happy Eyeballs v2 behavior, improving API reliability:
require 'net/http'
require 'uri'
require 'json'
class ResilientAPIClient
def initialize(base_url, timeout: 10)
@base_uri = URI(base_url)
@timeout = timeout
end
def get(path, retries: 3)
uri = @base_uri + path
retry_count = 0
begin
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.open_timeout = @timeout
http.read_timeout = @timeout
# Happy Eyeballs v2 optimizes this connection
response = http.get(uri.path)
case response.code.to_i
when 200..299
JSON.parse(response.body)
when 400..499
raise "Client error: #{response.code} - #{response.body}"
when 500..599
raise "Server error: #{response.code}"
else
raise "Unexpected response: #{response.code}"
end
rescue => e
retry_count += 1
if retry_count <= retries
sleep(2 ** retry_count) # Exponential backoff
retry
else
raise "API request failed after #{retries} retries: #{e.message}"
end
end
end
end
# Production usage
client = ResilientAPIClient.new('https://api.service.com')
begin
data = client.get('/health')
puts "Service healthy: #{data['status']}"
rescue => e
puts "Health check failed: #{e.message}"
end
Database connection pools benefit significantly from Happy Eyeballs v2 when connecting to database servers with both IPv4 and IPv6 addresses:
require 'socket'
class DatabaseConnectionPool
def initialize(host, port, pool_size: 20, connect_timeout: 5)
@host = host
@port = port
@pool_size = pool_size
@connect_timeout = connect_timeout
@pool = Queue.new
@stats = {
created: 0,
reused: 0,
ipv4_connections: 0,
ipv6_connections: 0
}
# Pre-populate pool
pool_size.times { @pool << create_connection }
end
def with_connection
connection = @pool.pop
@stats[:reused] += 1
begin
# Verify connection is still valid
connection = create_connection if connection.closed?
yield connection
ensure
@pool << connection unless connection.closed?
end
end
def stats
@stats.dup
end
private
def create_connection
socket = Socket.tcp(@host, @port, connect_timeout: @connect_timeout)
@stats[:created] += 1
# Track which IP version was selected
case socket.remote_address.afamily
when Socket::AF_INET
@stats[:ipv4_connections] += 1
when Socket::AF_INET6
@stats[:ipv6_connections] += 1
end
socket
end
end
# Production database client
db_pool = DatabaseConnectionPool.new('db.cluster.internal', 5432, pool_size: 50)
# Simulate application load
100.times do |i|
Thread.new do
db_pool.with_connection do |socket|
# Simulate database query
socket.write("SELECT current_timestamp;\n")
response = socket.recv(1024)
# Process response...
end
end
end
sleep 2 # Let threads complete
puts "Connection stats: #{db_pool.stats}"
Microservice communication patterns benefit from automatic Happy Eyeballs v2 optimization in service mesh environments:
class ServiceDiscovery
def initialize
@services = {
'user-service' => ['user-svc.internal', 8080],
'order-service' => ['order-svc.internal', 8080],
'payment-service' => ['payment-svc.internal', 8080]
}
@connection_cache = {}
end
def call_service(service_name, path, payload = nil)
host, port = @services[service_name]
raise "Unknown service: #{service_name}" unless host
# Connection caching with Happy Eyeballs v2 benefits
cache_key = "#{host}:#{port}"
unless @connection_cache[cache_key]&.then { |s| !s.closed? }
@connection_cache[cache_key] = Socket.tcp(host, port, connect_timeout: 3)
end
socket = @connection_cache[cache_key]
# Build HTTP request
request = build_http_request(path, payload)
socket.write(request)
# Read response
response = socket.recv(4096)
parse_http_response(response)
rescue => e
# Clean up failed connection
@connection_cache[cache_key]&.close
@connection_cache.delete(cache_key)
raise "Service call failed: #{e.message}"
end
private
def build_http_request(path, payload)
method = payload ? 'POST' : 'GET'
headers = ["#{method} #{path} HTTP/1.1", "Host: #{host}"]
if payload
json_payload = payload.to_json
headers << "Content-Type: application/json"
headers << "Content-Length: #{json_payload.bytesize}"
headers << ""
headers << json_payload
end
headers.join("\r\n") + "\r\n\r\n"
end
def parse_http_response(response)
lines = response.split("\r\n")
status_line = lines.first
status_code = status_line.split(' ')[1].to_i
# Find body start
body_start = lines.find_index('') + 1
body = lines[body_start..-1].join("\r\n") if body_start < lines.length
{ status: status_code, body: body }
end
end
# Production service communication
discovery = ServiceDiscovery.new
begin
# These calls automatically use Happy Eyeballs v2
user_data = discovery.call_service('user-service', '/users/123')
order_data = discovery.call_service('order-service', '/orders', { user_id: 123 })
puts "User: #{user_data[:body]}"
puts "Order: #{order_data[:body]}"
rescue => e
puts "Service communication error: #{e.message}"
end
Load balancer and proxy configurations work transparently with Happy Eyeballs v2:
require 'socket'
require 'thread'
class LoadBalancer
def initialize(upstream_servers)
@upstreams = upstream_servers # Array of [host, port] pairs
@current_index = 0
@mutex = Mutex.new
@health_status = {}
start_health_checks
end
def get_connection
healthy_servers = @upstreams.select { |host, port| healthy?("#{host}:#{port}") }
raise "No healthy upstream servers" if healthy_servers.empty?
# Round-robin selection
@mutex.synchronize do
@current_index = (@current_index + 1) % healthy_servers.length
end
host, port = healthy_servers[@current_index]
Socket.tcp(host, port, connect_timeout: 3) # Happy Eyeballs v2 active
end
private
def start_health_checks
Thread.new do
loop do
@upstreams.each do |host, port|
key = "#{host}:#{port}"
@health_status[key] = check_health(host, port)
end
sleep 30 # Health check interval
end
end
end
def check_health(host, port)
Socket.tcp(host, port, connect_timeout: 2) do |socket|
socket.write("GET /health HTTP/1.1\r\nHost: #{host}\r\n\r\n")
response = socket.recv(1024)
response.include?('200 OK')
end
rescue
false
end
def healthy?(server_key)
@health_status[server_key] != false
end
end
# Production load balancing
upstreams = [
['app1.internal', 8080],
['app2.internal', 8080],
['app3.internal', 8080]
]
balancer = LoadBalancer.new(upstreams)
# Handle requests with automatic failover
10.times do |i|
begin
connection = balancer.get_connection
puts "Request #{i}: Connected to #{connection.remote_address.ip_address}"
connection.close
rescue => e
puts "Request #{i}: Failed - #{e.message}"
end
end
Reference
Core Classes and Methods
Class/Method | Parameters | Returns | Description |
---|---|---|---|
Socket.tcp(host, port, **opts) |
host (String), port (Integer), options (Hash) |
Socket |
Creates TCP connection with Happy Eyeballs v2 |
TCPSocket.new(host, port) |
host (String), port (Integer) |
TCPSocket |
TCP socket with automatic dual-stack optimization |
TCPSocket.open(host, port, &block) |
host (String), port (Integer), block |
Object |
Block-scoped TCP connection |
Socket#local_address |
None | Addrinfo |
Local endpoint address information |
Socket#remote_address |
None | Addrinfo |
Remote endpoint address information |
Connection Options
Option | Type | Default | Description |
---|---|---|---|
:connect_timeout |
Integer/Float |
60 | Maximum connection establishment time |
:resolv_timeout |
Integer/Float |
30 | DNS resolution timeout |
:local_host |
String |
nil | Local interface to bind |
:local_port |
Integer |
nil | Local port to bind |
Address Family Constants
Constant | Value | Description |
---|---|---|
Socket::AF_INET |
2 | IPv4 address family |
Socket::AF_INET6 |
10 | IPv6 address family |
Socket::AF_UNSPEC |
0 | Unspecified address family |
Addrinfo Methods
Method | Returns | Description |
---|---|---|
#ip_address |
String |
IP address as string |
#ip_port |
Integer |
Port number |
#afamily |
Integer |
Address family constant |
#pfamily |
Integer |
Protocol family constant |
#socktype |
Integer |
Socket type constant |
#protocol |
Integer |
Protocol number |
#canonname |
String |
Canonical hostname |
Common Exceptions
Exception | Condition | Description |
---|---|---|
SocketError |
DNS/addressing issues | Name resolution or address format errors |
Errno::ETIMEDOUT |
Connection timeout | Neither IPv4 nor IPv6 connected in time |
Errno::ECONNREFUSED |
Port closed | Target port not accepting connections |
Errno::EHOSTUNREACH |
Routing failure | No route to destination host |
Errno::ENETUNREACH |
Network failure | Network interface or routing problem |
Errno::ENOTCONN |
Socket state | Socket not connected |
Errno::ECONNRESET |
Connection reset | Remote peer reset connection |
Environment Variables
Variable | Effect | Values |
---|---|---|
RUBY_IPV6 |
IPv6 preference | enabled , disabled |
RUBY_SOCKET_DEBUG |
Debug output | 1 enables socket debugging |
Happy Eyeballs v2 Timing
Phase | Default Timeout | Configurable Via |
---|---|---|
DNS Resolution | 30s | :resolv_timeout option |
IPv6 First Attempt | 250ms | Internal algorithm |
IPv4 Delay | 50ms after IPv6 start | Internal algorithm |
Total Connection | 60s | :connect_timeout option |
Integration Points
Library/Framework | Integration | Notes |
---|---|---|
Net::HTTP |
Automatic | All HTTP connections benefit |
OpenSSL::SSL::SSLSocket |
Transparent | TLS connections inherit optimization |
URI.open |
Automatic | File/HTTP URI opening optimized |
WebSocket libraries | Variable | Depends on underlying socket usage |
Database drivers | Variable | Depends on connection implementation |
Debugging Methods
Method | Purpose | Usage |
---|---|---|
Socket.getaddrinfo(host, service) |
DNS resolution preview | Shows addresses Happy Eyeballs v2 will consider |
Socket.ip_address_list |
Local interfaces | Lists available local addresses |
Socket.gethostname |
Local hostname | Current machine hostname |
Performance Characteristics
Scenario | IPv6 Available | IPv4 Available | Typical Behavior |
---|---|---|---|
Dual-stack optimal | ✓ | ✓ | IPv6 preferred, ~50ms total |
IPv6 unreachable | ✗ | ✓ | IPv4 fallback, ~300ms total |
IPv4 only | ✗ | ✓ | Direct IPv4, ~50ms total |
IPv6 only | ✓ | ✗ | Direct IPv6, ~50ms total |
Both unreachable | ✗ | ✗ | Timeout after connect_timeout |
Configuration Examples
# Basic connection with timeout
Socket.tcp('example.com', 80, connect_timeout: 5)
# Connection with local binding
Socket.tcp('example.com', 80, local_host: '192.168.1.100')
# DNS timeout customization
Socket.tcp('slow-dns.com', 80, resolv_timeout: 10, connect_timeout: 15)