CrackedRuby CrackedRuby

Overview

Keep-alive connections maintain TCP connections between client and server for multiple HTTP requests, eliminating the overhead of repeated connection establishment and teardown. In HTTP/1.0, each request required a new TCP connection, incurring the cost of a three-way handshake, slow start congestion control, and connection termination for every resource. HTTP/1.1 introduced persistent connections as the default behavior, changing web performance characteristics substantially.

The mechanism addresses a fundamental inefficiency in early HTTP architecture. When browsers request a web page with dozens of resources (HTML, CSS, JavaScript, images), creating separate connections for each resource multiplies network overhead. Each connection requires DNS resolution, TCP handshake (one round-trip), TLS handshake if using HTTPS (two additional round-trips), and congestion window initialization. For a page with 50 resources, this overhead becomes significant.

Modern web applications rely on connection persistence. API clients making multiple requests to the same server, web scrapers fetching numerous pages, and microservices communicating internally all benefit from connection reuse. The difference between fresh connections and persistent connections can mean the difference between 200ms and 20ms response times when network latency is involved.

# Without keep-alive: new connection per request
require 'net/http'

uri = URI('https://api.example.com/users/1')
response1 = Net::HTTP.get_response(uri)  # Connection 1

uri = URI('https://api.example.com/users/2')
response2 = Net::HTTP.get_response(uri)  # Connection 2 (new handshake)
# With keep-alive: connection reused
require 'net/http'

Net::HTTP.start('api.example.com', 443, use_ssl: true) do |http|
  response1 = http.get('/users/1')  # Connection established
  response2 = http.get('/users/2')  # Same connection reused
  response3 = http.get('/users/3')  # Still same connection
end

Connection persistence operates at the transport layer but requires coordination with the application layer. The server must explicitly support keep-alive and communicate connection parameters through HTTP headers. Both client and server must manage connection state, handle timeouts, and detect stale connections.

Key Principles

Connection persistence operates through several coordinated mechanisms that maintain TCP state across multiple HTTP transactions. The fundamental principle involves the server declaring its willingness to keep connections alive and the client choosing to reuse those connections for subsequent requests.

Connection Lifecycle Management

A persistent connection transitions through distinct states: establishment, active usage, idle periods, and eventual closure. During establishment, standard TCP handshake occurs. The server includes Connection: keep-alive header (HTTP/1.0) or omits Connection: close header (HTTP/1.1) to signal persistence support. The connection enters active state when transmitting request/response data, then transitions to idle state awaiting the next request.

Idle connections present the primary challenge. Both client and server must decide when to close idle connections. Servers typically implement timeout policies: connections inactive for a specified duration close automatically. Clients must detect when servers have closed connections and handle reconnection transparently.

Request-Response Boundaries

HTTP defines message boundaries through Content-Length headers or chunked transfer encoding. Without clear boundaries, the client cannot determine where one response ends and the next begins on a persistent connection. The Content-Length header specifies exact byte count. Chunked encoding uses special framing:

HTTP/1.1 200 OK
Transfer-Encoding: chunked

5
Hello
6
 World
0

The final chunk (size 0) signals response completion. Incorrect boundary handling results in data corruption or connection stalls.

Connection Headers

The Connection header controls persistence behavior. In HTTP/1.0, clients must explicitly request persistence: Connection: keep-alive. The server responds with Connection: keep-alive and Keep-Alive header parameters. HTTP/1.1 reversed the default: connections persist unless explicitly closed with Connection: close.

The Keep-Alive header provides connection parameters:

Connection: keep-alive
Keep-Alive: timeout=5, max=100

This signals the server will maintain the connection for 5 seconds of idle time and accept up to 100 requests on this connection. These values inform client connection pool management.

Connection Pooling

Clients typically maintain connection pools rather than single connections. For a given host and port, the pool holds multiple connections (often 2-6 per host). When making requests, the client borrows an available connection, uses it, and returns it to the pool. This pattern handles concurrent requests to the same host while respecting connection limits.

Connection pools must handle connection health. Stale connections that servers closed remain in the pool until detected. Clients typically verify connection health before reusing pooled connections or handle connection errors by retrying with fresh connections.

Timeout Mechanisms

Three distinct timeouts govern persistent connections:

  • Connection timeout: Maximum time to establish initial TCP connection
  • Read timeout: Maximum time waiting for response data after request sent
  • Idle timeout: Maximum time connection remains open without activity

Idle timeout management requires coordination. Clients should close connections before server timeout expires to prevent servers from closing connections the client believes active. In practice, clients set idle timeouts slightly shorter than server timeouts.

Graceful Closure

Either party can close persistent connections. The server sends Connection: close header in a response to signal this is the final transaction on this connection. The client must not send additional requests on this connection. Clients similarly send Connection: close when they choose to terminate persistence.

Ungraceful closure occurs when TCP connections terminate without HTTP-level coordination. Network failures, server crashes, or process termination result in TCP RST or FIN without proper HTTP signaling. Clients must detect these scenarios and handle reconnection.

Ruby Implementation

Ruby's standard library and third-party gems provide varying levels of keep-alive support. Understanding each library's connection management model determines appropriate usage patterns.

Net::HTTP Persistent Connections

The standard library Net::HTTP supports persistent connections through its block-based API. Calling Net::HTTP.start establishes a connection, yields it to the block, and maintains the connection for multiple requests within that block:

require 'net/http'

uri = URI('https://api.github.com')

Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  # Connection established once
  
  # First request
  request = Net::HTTP::Get.new('/users/octocat')
  request['User-Agent'] = 'Ruby Script'
  response = http.request(request)
  
  # Second request reuses connection
  request = Net::HTTP::Get.new('/users/octocat/repos')
  response = http.request(request)
  
  # Third request still reuses connection
  request = Net::HTTP::Get.new('/users/octocat/followers')
  response = http.request(request)
end
# Connection closed when block exits

Net::HTTP also supports manual connection management without blocks:

require 'net/http'

http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.start

# Connection established and persisted
response1 = http.get('/resource1')
response2 = http.get('/resource2')

http.finish  # Explicitly close connection

The manual approach requires explicit start and finish calls. Failure to call finish leaves connections open until garbage collection or process termination.

Net::HTTP does not provide built-in connection pooling. Each Net::HTTP instance represents a single connection. Applications requiring concurrent requests to the same host must manage multiple Net::HTTP instances:

require 'net/http'
require 'thread'

uri = URI('https://api.example.com')
connections = Array.new(5) do
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.start
  http
end

threads = (1..10).map do |i|
  Thread.new do
    connection = connections[i % 5]  # Round-robin distribution
    response = connection.get("/items/#{i}")
    puts "Thread #{i}: #{response.code}"
  end
end

threads.each(&:join)
connections.each(&:finish)

Net::HTTP::Persistent

The net-http-persistent gem extends Net::HTTP with automatic connection pooling and reconnection handling:

require 'net/http/persistent'

http = Net::HTTP::Persistent.new(name: 'my_app')

# First request establishes connection
uri = URI('https://api.example.com/users/1')
response = http.request(uri)

# Subsequent requests reuse connection automatically
uri = URI('https://api.example.com/users/2')
response = http.request(uri)

# Different host uses different connection from pool
uri = URI('https://api.other.com/data')
response = http.request(uri)

# Shutdown all connections
http.shutdown

The gem manages per-host connection pools automatically. It handles stale connection detection and reconnection transparently. Configuration options control pool behavior:

require 'net/http/persistent'

http = Net::HTTP::Persistent.new(
  name: 'my_app',
  pool_size: 10,        # Connections per host
  idle_timeout: 5,      # Seconds before closing idle connections
  read_timeout: 10,     # Response read timeout
  open_timeout: 5       # Connection establishment timeout
)

# Override timeout for specific request
uri = URI('https://slow-api.example.com/data')
http.override_headers['User-Agent'] = 'CustomAgent/1.0'
response = http.request(uri)

Faraday with Connection Pooling

Faraday provides a high-level HTTP client interface with adapter-based architecture. The default adapter uses Net::HTTP without persistent connections. The net_http_persistent adapter enables connection pooling:

require 'faraday'
require 'net/http/persistent'

conn = Faraday.new(url: 'https://api.example.com') do |f|
  f.adapter :net_http_persistent, pool_size: 5
end

# Connections managed automatically
response1 = conn.get('/users/1')
response2 = conn.get('/users/2')
response3 = conn.get('/users/3')

Faraday's middleware stack allows customization while maintaining persistent connections:

require 'faraday'

conn = Faraday.new(url: 'https://api.example.com') do |f|
  f.request :json
  f.response :json
  f.response :raise_error
  f.adapter :net_http_persistent, pool_size: 10 do |http|
    http.idle_timeout = 10
    http.read_timeout = 30
  end
end

# Middleware processes requests/responses
# Connection pooling handles HTTP transport
response = conn.post('/users') do |req|
  req.body = { name: 'Alice', email: 'alice@example.com' }
end

HTTP.rb Library

The http gem provides a chainable HTTP client with built-in persistent connection support:

require 'http'

# Persistent client
client = HTTP.persistent('https://api.example.com')

# Requests reuse connection
response1 = client.get('/users/1')
response2 = client.get('/users/2')
response3 = client.get('/users/3')

# Close persistent connection
client.close

The library handles connection state automatically and provides fluent API for request customization:

require 'http'

client = HTTP.persistent('https://api.example.com')
  .headers(user_agent: 'MyApp/1.0')
  .timeout(connect: 5, read: 10)

response = client.get('/data')
puts response.body.to_s

client.close

Connection Pool Implementation

Building a custom connection pool demonstrates the underlying concepts:

require 'net/http'
require 'monitor'

class ConnectionPool
  def initialize(host, port, pool_size: 5, idle_timeout: 5)
    @host = host
    @port = port
    @pool_size = pool_size
    @idle_timeout = idle_timeout
    @available = []
    @in_use = {}
    @monitor = Monitor.new
  end

  def with_connection
    connection = acquire
    begin
      yield connection
    ensure
      release(connection)
    end
  end

  private

  def acquire
    @monitor.synchronize do
      # Remove stale connections
      @available.reject! { |conn| stale?(conn) }
      
      # Return available connection
      if @available.any?
        conn = @available.pop
        @in_use[conn] = Time.now
        return conn
      end
      
      # Create new connection if under limit
      if @in_use.size < @pool_size
        conn = create_connection
        @in_use[conn] = Time.now
        return conn
      end
      
      # Wait for available connection
      sleep 0.1
      acquire
    end
  end

  def release(connection)
    @monitor.synchronize do
      @in_use.delete(connection)
      @available.push(connection) if connection.started?
    end
  end

  def create_connection
    http = Net::HTTP.new(@host, @port)
    http.use_ssl = true
    http.start
    http
  end

  def stale?(connection)
    !connection.started? || 
    (Time.now - @in_use.fetch(connection, Time.now)) > @idle_timeout
  end
end

# Usage
pool = ConnectionPool.new('api.example.com', 443, pool_size: 5)

5.times do |i|
  pool.with_connection do |http|
    response = http.get("/items/#{i}")
    puts "Request #{i}: #{response.code}"
  end
end

Practical Examples

API Client with Connection Reuse

An API client making multiple requests demonstrates connection reuse benefits:

require 'net/http/persistent'
require 'json'

class GitHubClient
  def initialize(token)
    @token = token
    @http = Net::HTTP::Persistent.new(name: 'github_client')
  end

  def user_repositories(username)
    uri = URI("https://api.github.com/users/#{username}/repos")
    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "token #{@token}"
    request['Accept'] = 'application/vnd.github.v3+json'
    
    response = @http.request(uri, request)
    JSON.parse(response.body)
  end

  def repository_details(owner, repo)
    uri = URI("https://api.github.com/repos/#{owner}/#{repo}")
    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "token #{@token}"
    request['Accept'] = 'application/vnd.github.v3+json'
    
    response = @http.request(uri, request)
    JSON.parse(response.body)
  end

  def repository_commits(owner, repo)
    uri = URI("https://api.github.com/repos/#{owner}/#{repo}/commits")
    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "token #{@token}"
    request['Accept'] = 'application/vnd.github.v3+json'
    
    response = @http.request(uri, request)
    JSON.parse(response.body)
  end

  def close
    @http.shutdown
  end
end

# All requests reuse connection to api.github.com
client = GitHubClient.new(ENV['GITHUB_TOKEN'])
repos = client.user_repositories('octocat')
details = client.repository_details('octocat', repos.first['name'])
commits = client.repository_commits('octocat', repos.first['name'])
client.close

Web Scraper with Connection Pool

A web scraper fetching multiple pages benefits from connection pooling:

require 'net/http/persistent'
require 'nokogiri'
require 'uri'

class WebScraper
  def initialize
    @http = Net::HTTP::Persistent.new(
      name: 'scraper',
      pool_size: 10,
      idle_timeout: 10
    )
  end

  def scrape_articles(base_url, page_count)
    results = []
    
    page_count.times do |page|
      url = URI("#{base_url}?page=#{page + 1}")
      response = @http.request(url)
      
      if response.code == '200'
        doc = Nokogiri::HTML(response.body)
        
        doc.css('article').each do |article|
          title = article.css('h2').text.strip
          link = article.css('a').first['href']
          
          # Fetch full article (reuses connection)
          full_url = URI.join(base_url, link)
          article_response = @http.request(full_url)
          article_doc = Nokogiri::HTML(article_response.body)
          
          content = article_doc.css('.content').text.strip
          
          results << {
            title: title,
            url: full_url.to_s,
            content: content
          }
        end
      end
      
      sleep 0.5  # Rate limiting
    end
    
    results
  end

  def close
    @http.shutdown
  end
end

scraper = WebScraper.new
articles = scraper.scrape_articles('https://example.com/blog', 5)
scraper.close

puts "Scraped #{articles.size} articles"

Microservice Communication

Microservices making inter-service calls benefit from persistent connections:

require 'faraday'
require 'json'

class UserService
  def initialize(auth_service_url, profile_service_url)
    @auth_conn = Faraday.new(url: auth_service_url) do |f|
      f.adapter :net_http_persistent, pool_size: 5
    end
    
    @profile_conn = Faraday.new(url: profile_service_url) do |f|
      f.adapter :net_http_persistent, pool_size: 5
    end
  end

  def authenticate(username, password)
    response = @auth_conn.post('/auth/login') do |req|
      req.headers['Content-Type'] = 'application/json'
      req.body = JSON.generate(username: username, password: password)
    end
    
    JSON.parse(response.body)
  end

  def get_profile(user_id, token)
    response = @profile_conn.get("/users/#{user_id}") do |req|
      req.headers['Authorization'] = "Bearer #{token}"
    end
    
    JSON.parse(response.body)
  end

  def update_profile(user_id, token, data)
    response = @profile_conn.put("/users/#{user_id}") do |req|
      req.headers['Authorization'] = "Bearer #{token}"
      req.headers['Content-Type'] = 'application/json'
      req.body = JSON.generate(data)
    end
    
    JSON.parse(response.body)
  end
end

service = UserService.new(
  'http://auth-service:3000',
  'http://profile-service:3001'
)

# Multiple calls reuse connections
auth_result = service.authenticate('alice', 'password')
token = auth_result['token']

profile = service.get_profile(123, token)
updated = service.update_profile(123, token, { bio: 'Updated bio' })

Batch Processing with Connection Reuse

Processing large datasets with API calls benefits from connection persistence:

require 'net/http/persistent'
require 'json'

class BatchProcessor
  def initialize(api_url, batch_size: 100)
    @api_url = api_url
    @batch_size = batch_size
    @http = Net::HTTP::Persistent.new(
      name: 'batch_processor',
      idle_timeout: 30,
      read_timeout: 60
    )
  end

  def process_records(records)
    results = []
    failures = []
    
    records.each_slice(@batch_size) do |batch|
      batch.each do |record|
        begin
          uri = URI("#{@api_url}/process")
          request = Net::HTTP::Post.new(uri)
          request['Content-Type'] = 'application/json'
          request.body = JSON.generate(record)
          
          response = @http.request(uri, request)
          
          if response.code == '200'
            results << JSON.parse(response.body)
          else
            failures << { record: record, error: response.body }
          end
        rescue => e
          failures << { record: record, error: e.message }
        end
      end
      
      # Brief pause between batches
      sleep 0.1
    end
    
    { successes: results, failures: failures }
  end

  def close
    @http.shutdown
  end
end

# Process 10,000 records using same connection
records = (1..10_000).map { |i| { id: i, data: "Record #{i}" } }
processor = BatchProcessor.new('https://api.example.com', batch_size: 100)
result = processor.process_records(records)
processor.close

puts "Processed: #{result[:successes].size}"
puts "Failed: #{result[:failures].size}"

Performance Considerations

Connection persistence provides measurable performance improvements by eliminating repeated connection establishment overhead. The magnitude of improvement depends on network latency, TLS usage, and request frequency.

Latency Reduction

Each new TCP connection requires a three-way handshake: client sends SYN, server responds with SYN-ACK, client sends ACK. This process consumes one round-trip time (RTT). For HTTPS connections, TLS handshake adds two more round-trips (TLS 1.2) or one round-trip (TLS 1.3). Without keep-alive:

Non-persistent connection (HTTP):
- DNS lookup: 20ms
- TCP handshake: 50ms (RTT)
- Request/Response: 50ms (RTT)
Total: 120ms per request

Non-persistent connection (HTTPS with TLS 1.2):
- DNS lookup: 20ms
- TCP handshake: 50ms
- TLS handshake: 100ms (2 RTT)
- Request/Response: 50ms
Total: 220ms per request

With persistent connections, subsequent requests skip connection setup:

Persistent connection (HTTPS):
- First request: 220ms (as above)
- Second request: 50ms (RTT only)
- Third request: 50ms (RTT only)
Average: 106ms per request (61% improvement)

The benefit compounds with more requests. Ten requests over persistent connection average 67ms each versus 220ms for separate connections.

Connection Limits

Browsers and HTTP clients enforce per-host connection limits. Browsers typically allow 6 simultaneous connections per host. Without keep-alive, these connections close after each response, allowing new connections. With keep-alive, connections remain open, potentially blocking new requests until existing requests complete.

Applications must balance connection pool size against resource usage. Too few connections create bottlenecks under load. Too many connections waste server resources and socket file descriptors. A common approach:

Connection Pool Size = (Expected Peak Concurrent Requests / Average Request Duration) * 1.5

Example:
- Peak: 100 requests/second to single host
- Average duration: 100ms = 0.1s
- Concurrent requests: 100 * 0.1 = 10
- Pool size: 10 * 1.5 = 15 connections

Memory and Resource Usage

Each persistent connection consumes resources on both client and server:

  • TCP socket file descriptors (limited by OS)
  • Kernel memory for socket buffers (typically 128KB send + 128KB receive)
  • Application memory for connection state
  • Server worker threads or event loop resources

Servers with 10,000 persistent connections consume:

Socket buffers: 10,000 * 256KB = 2.5GB kernel memory
File descriptors: 10,000 (requires ulimit adjustment)
Application memory: varies by implementation

Servers must configure maximum concurrent connections and implement connection limiting. When limits reached, servers can:

  • Reject new connections (TCP RST)
  • Send Connection: close on existing connections to free slots
  • Queue incoming connections (TCP backlog)

Idle Connection Overhead

Idle persistent connections consume resources without performing work. Aggressive idle timeout policies (1-5 seconds) reduce resource waste but increase connection churn. Conservative timeouts (60-300 seconds) reduce handshake overhead but waste resources.

The optimal timeout depends on request patterns:

High-frequency APIs (requests every few seconds):
- Long timeout (60-120s)
- Minimize reconnection overhead

Low-frequency APIs (requests every few minutes):
- Short timeout (5-15s)
- Minimize idle resource consumption

Bursty traffic (occasional high activity):
- Medium timeout (30-60s)
- Balance responsiveness and resources

Concurrent Request Handling

HTTP/1.1 persistent connections process requests serially: the second request waits for the first response. Connection pooling enables parallelism by maintaining multiple connections per host:

require 'net/http/persistent'
require 'thread'

http = Net::HTTP::Persistent.new(name: 'parallel', pool_size: 10)

# 10 concurrent requests use 10 different connections
threads = (1..10).map do |i|
  Thread.new do
    uri = URI("https://api.example.com/data/#{i}")
    response = http.request(uri)
    puts "Thread #{i}: #{response.code}"
  end
end

threads.each(&:join)
http.shutdown

HTTP/2 multiplexing allows multiple concurrent requests over a single connection, but HTTP/2 adoption in Ruby clients varies by library.

Benchmark Comparison

Comparing persistent and non-persistent connections:

require 'net/http'
require 'net/http/persistent'
require 'benchmark'

url = URI('https://httpbin.org/get')
iterations = 50

# Non-persistent (new connection per request)
non_persistent_time = Benchmark.realtime do
  iterations.times do
    Net::HTTP.get_response(url)
  end
end

# Persistent connection
persistent_time = Benchmark.realtime do
  http = Net::HTTP::Persistent.new(name: 'bench')
  iterations.times do
    http.request(url)
  end
  http.shutdown
end

puts "Non-persistent: #{non_persistent_time.round(2)}s"
puts "Persistent: #{persistent_time.round(2)}s"
puts "Improvement: #{((1 - persistent_time / non_persistent_time) * 100).round(1)}%"

# Typical output:
# Non-persistent: 12.34s
# Persistent: 4.56s
# Improvement: 63.0%

Security Implications

Persistent connections introduce security considerations distinct from non-persistent connections. Connection lifetime, resource management, and state handling create attack surfaces requiring mitigation.

Connection Hijacking

Long-lived connections provide extended windows for session hijacking. If an attacker intercepts a persistent connection through network compromise (ARP spoofing, DNS hijacking), they gain access to all subsequent requests on that connection. TLS mitigates this through encryption, but implementation flaws or certificate validation bypasses enable attacks.

Clients must validate TLS certificates on connection establishment and should not bypass certificate validation for convenience:

# Insecure: disables certificate verification
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE  # NEVER do this

# Secure: proper certificate verification
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER

Denial of Service Through Connection Exhaustion

Attackers can exhaust server connection limits by opening many persistent connections and sending minimal traffic to keep them alive. This prevents legitimate clients from connecting. The Slowloris attack exploits this by opening hundreds of connections and sending partial HTTP requests slowly.

Servers mitigate this through:

  • Strict connection limits per IP address
  • Aggressive timeout policies
  • Rate limiting on connection establishment
  • Detection of slow request patterns

Clients can unintentionally cause similar issues through connection pool leaks. Connections acquired but never released accumulate until the pool exhausts:

# Dangerous: connection not released on error
def fetch_data(http, path)
  http.request(URI("https://api.example.com#{path}"))
  # If an exception occurs, connection remains in use
end

# Safe: ensure release with ensure block
def fetch_data(http, path)
  http.request(URI("https://api.example.com#{path}"))
ensure
  # Connection automatically released by pool
end

Authentication Token Lifetime

Long-lived connections with authentication tokens create token lifetime issues. A token valid at connection establishment might expire during the connection lifetime. Servers must verify tokens on each request, not just at connection establishment:

require 'net/http/persistent'
require 'json'

class AuthenticatedClient
  def initialize(api_url)
    @api_url = api_url
    @http = Net::HTTP::Persistent.new(name: 'auth_client')
    @token = nil
    @token_expiry = nil
  end

  def request(path)
    ensure_valid_token
    
    uri = URI("#{@api_url}#{path}")
    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "Bearer #{@token}"
    
    response = @http.request(uri, request)
    
    # Handle token expiration
    if response.code == '401'
      refresh_token
      request['Authorization'] = "Bearer #{@token}"
      response = @http.request(uri, request)
    end
    
    response
  end

  private

  def ensure_valid_token
    if @token.nil? || Time.now >= @token_expiry
      refresh_token
    end
  end

  def refresh_token
    # Obtain new token
    # Set @token and @token_expiry
  end
end

Information Leakage Through Connection Reuse

Persistent connections can leak information between requests if proper header management fails. Headers from previous requests might persist or credentials might accidentally apply to unintended requests:

# Problematic: headers persist across requests
http = Net::HTTP::Persistent.new(name: 'client')
http.override_headers['Authorization'] = 'Bearer secret-token'

# This authorization header applies to ALL requests
response1 = http.request(URI('https://api.example.com/public'))
response2 = http.request(URI('https://api.example.com/private'))

# Better: set headers per request
http = Net::HTTP::Persistent.new(name: 'client')

uri1 = URI('https://api.example.com/public')
request1 = Net::HTTP::Get.new(uri1)
response1 = http.request(uri1, request1)

uri2 = URI('https://api.example.com/private')
request2 = Net::HTTP::Get.new(uri2)
request2['Authorization'] = 'Bearer secret-token'
response2 = http.request(uri2, request2)

Server-Side Resource Exhaustion

Servers must protect against clients holding connections indefinitely. Without proper timeout enforcement, malicious or buggy clients can consume all available connection slots. Server administrators must configure:

# Nginx example
keepalive_timeout 65;           # 65 seconds idle timeout
keepalive_requests 100;         # Max 100 requests per connection
send_timeout 60;                # 60 seconds to send response
client_body_timeout 60;         # 60 seconds to receive request body

Clients should respect server connection policies and not attempt to circumvent timeout mechanisms.

TLS Session Resumption Security

TLS session resumption reduces TLS handshake overhead on new connections. However, session tickets or session IDs can be stolen and replayed. Servers should implement session ticket key rotation and forward secrecy through ephemeral key exchange.

Clients using persistent connections reduce exposure to session resumption attacks by minimizing new TLS handshakes. However, if connections close and reestablish frequently, session resumption becomes relevant.

Common Pitfalls

Stale Connection Detection

Clients often fail to detect when servers have closed idle connections. The client believes the connection remains alive but receives socket errors on the next request attempt:

# Problem: no stale connection handling
http = Net::HTTP::Persistent.new(name: 'client')
response = http.request(uri)
# Long delay...
response = http.request(uri)  # Might fail with broken pipe

Solutions include:

  • Catching connection errors and retrying with fresh connection
  • Implementing connection health checks before reuse
  • Setting client idle timeout shorter than server timeout
# Better: retry on connection errors
def request_with_retry(http, uri, max_retries: 2)
  retries = 0
  begin
    http.request(uri)
  rescue Errno::EPIPE, Errno::ECONNRESET, EOFError => e
    retries += 1
    if retries <= max_retries
      http.shutdown
      http = Net::HTTP::Persistent.new(name: 'client')
      retry
    else
      raise
    end
  end
end

Incorrect Content-Length Handling

Failing to set correct Content-Length headers on requests causes connection confusion. The server cannot determine request boundaries and might wait indefinitely for more data:

# Problem: missing Content-Length
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.start

request = Net::HTTP::Post.new('/data')
request.body = '{"key": "value"}'
# Missing: request['Content-Length'] = request.body.bytesize
response = http.request(request)  # Might hang

Ruby's Net::HTTP sets Content-Length automatically for most request types, but manual manipulation requires care:

# Correct: ensure Content-Length matches body
request = Net::HTTP::Post.new('/data')
body = '{"key": "value"}'
request.body = body
request['Content-Length'] = body.bytesize

Proxy Server Complications

HTTP proxies complicate keep-alive behavior. Some proxies do not support persistent connections or close connections despite keep-alive headers. The Connection header becomes hop-by-hop, applying only to the immediate connection:

Client <--keep-alive--> Proxy <--separate--> Server

The client maintains a persistent connection to the proxy, but the proxy might open fresh connections to the origin server. Clients cannot assume end-to-end connection reuse through proxies.

Additionally, the CONNECT method for HTTPS proxying establishes a tunnel but connection persistence applies to the tunnel itself, not the encapsulated TLS connection:

# Proxy configuration
http = Net::HTTP.new('api.example.com', 443, 'proxy.example.com', 8080)
http.use_ssl = true
http.start

# Connection to proxy persists
# Tunneled connection to api.example.com is separate
response1 = http.get('/resource1')
response2 = http.get('/resource2')

Connection Pool Starvation

Setting pool size too small creates contention when concurrent requests exceed available connections. Threads or processes block waiting for available connections:

# Problem: pool size 1, but 10 concurrent requests
http = Net::HTTP::Persistent.new(name: 'client', pool_size: 1)

threads = (1..10).map do |i|
  Thread.new do
    # All threads compete for single connection
    uri = URI("https://api.example.com/data/#{i}")
    response = http.request(uri)
    puts "Thread #{i}: #{response.code}"
  end
end

threads.each(&:join)

Monitor connection pool usage and adjust pool size based on observed concurrency patterns.

Ignoring Server Connection Limits

Clients that exceed server connection limits per IP address face rejected connections or rate limiting. Servers might return 503 Service Unavailable or close connections. Clients must:

  • Respect server-specified connection limits (via headers or documentation)
  • Implement backoff and retry logic
  • Distribute load across multiple IP addresses when appropriate

Memory Leaks in Connection Pools

Improper connection pool implementations can leak connections that remain open indefinitely:

# Problem: connections not returned to pool
class LeakyPool
  def initialize
    @connections = []
  end

  def get_connection
    @connections.detect(&:idle?) || create_connection
    # Connection marked as in-use but never released
  end

  def create_connection
    conn = Net::HTTP.new('api.example.com', 443)
    conn.use_ssl = true
    conn.start
    @connections << conn
    conn
  end
end

Proper pools track connection state and implement explicit release mechanisms or automatic release through block APIs.

Misunderstanding HTTP/1.1 Pipelining

HTTP/1.1 pipelining allows sending multiple requests without waiting for responses, but most clients and servers disable this feature. Pipelining introduces complexity around request ordering and error handling. Ruby's standard HTTP clients do not support pipelining. Attempting to implement pipelining manually leads to protocol violations.

Reference

HTTP Headers

Header Direction Description
Connection Both Controls connection persistence (keep-alive or close)
Keep-Alive Both Connection parameters: timeout and max requests
Content-Length Both Byte length of message body, required for boundary detection
Transfer-Encoding Both Indicates chunked encoding for unknown length messages

Connection Header Values

Value HTTP Version Meaning
keep-alive HTTP/1.0 Request or signal persistent connection support
close HTTP/1.1 Close connection after current request/response
(omitted) HTTP/1.1 Default: connection persists unless explicitly closed

Keep-Alive Header Parameters

Parameter Type Description
timeout Integer Seconds the connection remains idle before closing
max Integer Maximum requests allowed on this connection

Common Timeout Values

Timeout Type Typical Range Use Case
Connection establishment 5-30 seconds Time to complete TCP handshake and TLS handshake
Read timeout 10-60 seconds Time to receive complete response after request sent
Idle timeout (client) 5-300 seconds Client closes idle connection after this duration
Idle timeout (server) 5-120 seconds Server closes idle connection after this duration

Ruby Library Comparison

Library Connection Pooling Auto Reconnect Configuration API
Net::HTTP Manual No Low-level, verbose
Net::HTTP::Persistent Automatic per-host Yes Simple, good defaults
Faraday Adapter-dependent Adapter-dependent High-level, middleware-based
HTTP.rb Built-in persistent mode Yes Fluent, chainable

Connection Pool Configuration

Parameter Typical Value Consideration
Pool size per host 5-20 Balance concurrency vs resources
Idle timeout 5-60 seconds Shorter for infrequent requests
Max lifetime 300-3600 seconds Force periodic reconnection
Connection timeout 5-30 seconds Network latency dependent
Read timeout 30-120 seconds Request complexity dependent

Server Configuration Examples

Server Timeout Directive Max Requests Directive
Nginx keepalive_timeout keepalive_requests
Apache KeepAliveTimeout MaxKeepAliveRequests
Puma persistent_timeout (not configurable)
Unicorn timeout (does not support keep-alive)

Net::HTTP::Persistent Options

Option Type Default Description
name String required Unique identifier for connection pool
pool_size Integer Process connections Connections per host
idle_timeout Integer 5 Seconds before closing idle connection
read_timeout Integer 30 Response read timeout
open_timeout Integer 30 Connection establishment timeout
socket_options Array [] TCP socket options

Error Codes Related to Connection Issues

Error Class Cause Resolution
Errno::EPIPE Broken pipe, server closed connection Retry with new connection
Errno::ECONNRESET Connection reset by peer Retry with new connection
EOFError Unexpected end of file, connection closed Retry with new connection
Net::OpenTimeout Connection establishment timeout Check network, increase timeout
Net::ReadTimeout Response read timeout Check server load, increase timeout

Connection States

State Description Client Action
Establishing TCP and TLS handshake in progress Wait for completion
Active Transmitting request or response data Continue transmission
Idle No active transmission, awaiting next request Reuse or close based on policy
Closed Connection terminated Create new connection if needed
Stale Server closed but client unaware Detect on next request, reconnect