CrackedRuby logo

CrackedRuby

Happy Eyeballs v2

A comprehensive guide to using Happy Eyeballs v2 connection algorithm in Ruby's networking stack for optimized dual-stack connectivity.

Standard Library Network Programming
4.2.10

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)