Networking and HTTP

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Ruby ships a complete, if plain, networking stack in its standard library: socket for TCP, UDP and Unix-domain sockets, resolv for name resolution, uri for addresses, net/http for HTTP/1.1 clients, open-uri for "read this URL like a file", and openssl for TLS underneath all of them. Most application code never touches sockets and uses either Net::HTTP or a community client built on it, but every one of those layers shows through in the exceptions it raises, in its timeouts and in its security defaults, so this page goes from the bottom up: sockets, then URI, then Net::HTTP (requests, responses, timeouts, retries, redirects, proxies, encodings and TLS), then OpenURI, then how to handle the failures of a network call correctly, then concurrency.

Related pages: files and pipes share the IO interface with sockets (IO, Files and Directories); child processes and the removal of Kernel#open("|cmd") are in Processes, Signals and Shell; JSON bodies are parsed in Serialization: JSON, YAML, CSV and Marshal; the exception mechanics (rescue lists, retry, cause) are in Exceptions and Error Handling; waiting on the network from several threads is a case of Threads and the GVL, and Fibers and the Fiber Scheduler describes the non-blocking alternative; the safety rules (SSRF, certificate checks, URI.open on user input) are collected in Security; the other standard libraries are in Standard Library Tour. For the same ideas in other languages see Go net/http clients and servers and JavaScript browser networking.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The output shown in # => comments and in the output blocks is the real output, with port numbers abbreviated as PORT because the operating system picks a different one on each run. No example contacts a host on the Internet. Every client talks to a small server that is built from the standard library, runs in the same process and listens on 127.0.0.1 on a free port; the one exception to "loopback only" is a handful of strings such as shelf.example.com that are parsed or compared but never connected to. The server is shown first, because the later files load it with require_relative "shelf_server".

Networking in Ruby at a glance

Library What it gives you Availability in 4.0

socket

BasicSocket, IPSocket, TCPSocket, TCPServer, UDPSocket, UNIXSocket, UNIXServer, Socket, Addrinfo, Socket::Option.

Standard library extension; require "socket".

resolv

Resolv.getaddress, Resolv::DNS: a DNS and hosts-file resolver written in Ruby.

Default gem.

ipaddr

IPAddr: IPv4 and IPv6 addresses and networks (include?, loopback?, private?).

Default gem.

uri

URI, URI::HTTP, URI::HTTPS, URI::Generic, URI::MailTo, URI::File, URI::WS: parse, build and encode addresses.

Default gem (1.1.1 in 4.0.7).

net/http

Net::HTTP and the request and response classes: an HTTP/1.1 client (no HTTP/2, no cookie jar, no connection pool).

Default gem (net-http 0.9.1 in 4.0.7).

open-uri

URI#open, URI#read and URI.open on top of Net::HTTP: read a URL like a file.

Default gem (0.5.0).

openssl

OpenSSL::SSL::SSLContext, SSLSocket, X509 certificates, keys, digests: the TLS layer used by Net::HTTP.

Default gem (4.0.2); needs the OpenSSL library on the machine.

net/smtp, net/imap, net/pop, net/ftp

Clients for other protocols.

Bundled gems (they are installed with Ruby but not default gems, so a Bundler project lists them in its Gemfile).

WEBrick

A pure-Ruby HTTP server library.

Removed from the standard library in Ruby 3.0 and not installed with 4.0 (require "webrick" raises LoadError); use a gem or a Rack server for real servers.

Two rules of thumb run through the rest of the page. First, a network call can fail in more ways than a local call, and the failures come from three unrelated families of exception classes (SystemCallError, IOError and Timeout::Error for the transport, Net::ProtocolError for status codes, URI::Error for bad addresses), which is why the section on Handling failures exists. Second, every timeout defaults to 60 seconds in Net::HTTP and to "forever" in a bare socket read, so a program that does not set them can hang for a minute or indefinitely.

A local server for the examples

The file shelf_server.rb defines ShelfServer, a roughly one-hundred-line HTTP/1.1 server made of TCPServer, one thread per connection and a routing table. It exposes the shelf catalogue as JSON (/books, /books/<isbn>), accepts POST /books, and has a few routes that misbehave on purpose: /old redirects, /boom answers 500, /busy answers 503 with Retry-After, /slow sleeps for two seconds and /drop closes the connection without answering. ShelfServer.run starts it on a free port, yields it (server.base_url, server.port, server.connections) and stops it afterwards; a custom handler: lambda replaces the routing table. It is deliberately minimal (no chunked bodies, no TLS) and is not a pattern for a production server.

# frozen_string_literal: true

require "socket"
require "json"

# A tiny HTTP/1.1 server for the examples: a TCPServer bound to 127.0.0.1 on a port chosen
# by the operating system, one thread per connection, persistent connections supported.
class ShelfServer
  Request = Data.define(:verb, :path, :headers, :body)

  BOOKS = [
    { "isbn" => "9780596516178", "title" => "The Ruby Programming Language", "author" => "Ada Reader" },
    { "isbn" => "9781491904244", "title" => "Refactoring Shelves", "author" => "Grace Stacks" }
  ].freeze

  REASONS = {
    200 => "OK", 201 => "Created", 301 => "Moved Permanently", 404 => "Not Found",
    500 => "Internal Server Error", 503 => "Service Unavailable"
  }.freeze

  attr_reader :port, :connections

  def self.run(handler: nil)
    server = new(handler:)
    yield server
  ensure
    server&.stop
  end

  def initialize(handler: nil)
    @handler = handler || method(:route)
    @listener = TCPServer.new("127.0.0.1", 0)
    @port = @listener.addr[1]
    @connections = 0
    @acceptor = Thread.new { accept_loop }
  end

  def base_url = "http://127.0.0.1:#{port}"

  def stop
    @listener.close
    @acceptor.join
  end

  private

  def accept_loop
    loop do
      client = @listener.accept
      @connections += 1
      Thread.new(client) { |socket| serve(socket) }
    end
  rescue IOError
    nil # the listener was closed by #stop
  end

  def serve(socket)
    while (request = read_request(socket)) && (response = @handler.call(request))
      break if response == :drop

      write_response(socket, *response, head: request.verb == "HEAD")
    end
  rescue SystemCallError, IOError
    nil
  ensure
    socket.close
  end

  def read_request(socket)
    line = socket.gets("\r\n") or return
    verb, path, = line.split
    headers = read_headers(socket)
    body = socket.read(headers["content-length"].to_i) || ""
    Request.new(verb:, path:, headers:, body:)
  end

  def read_headers(socket)
    headers = {}
    while (line = socket.gets("\r\n")) && line != "\r\n"
      name, value = line.chomp.split(/:\s*/, 2)
      headers[name.downcase] = value
    end
    headers
  end

  def write_response(socket, status, headers, body, head: false)
    lines = ["HTTP/1.1 #{status} #{REASONS.fetch(status, 'Status')}"]
    headers.merge("Content-Length" => body.bytesize.to_s).each { |k, v| lines << "#{k}: #{v}" }
    socket.write("#{lines.join("\r\n")}\r\n\r\n#{body unless head}")
  end

  FIXED = {
    "GET /books" => ->(_) { json(200, BOOKS) },
    "GET /old" => ->(_) { [301, { "Location" => "/books" }, ""] },
    "GET /boom" => ->(_) { json(500, { "error" => "shelf collapsed" }) },
    "GET /busy" => ->(_) { [503, { "Retry-After" => "1" }, "try later"] },
    "GET /slow" => ->(_) { sleep(2) && json(200, {}) },
    "GET /drop" => ->(_) { :drop },
    "POST /books" => ->(request) { create(request) }
  }.freeze
  private_constant :FIXED

  def route(request)
    verb = request.verb == "HEAD" ? "GET" : request.verb
    action = FIXED["#{verb} #{request.path}"]
    return instance_exec(request, &action) if action
    return find_book(request.path) if verb == "GET" && request.path.start_with?("/books/")

    json(404, { "error" => "unknown route" })
  end

  def find_book(path)
    book = BOOKS.find { |b| b["isbn"] == path.delete_prefix("/books/") }
    book ? json(200, book) : json(404, { "error" => "no such book" })
  end

  def create(request)
    json(201, JSON.parse(request.body).merge("received_type" => request.headers["content-type"]))
  end

  def json(status, data)
    [status, { "Content-Type" => "application/json" }, JSON.generate(data)]
  end
end

The server uses TCPServer.new("127.0.0.1", 0): binding to the loopback address keeps it unreachable from other machines, and port 0 asks the kernel for a free port, which server.addr[1] reports. Data.define (Ruby 3.2) holds each request, and the Thread per connection is the simplest correct model for a server that is not meant to scale; the threads wait in gets and read, which release the GVL.

Sockets

TCPServer and TCPSocket

Ruby’s socket classes form a hierarchy that mirrors the BSD socket API. BasicSocket (a subclass of IO) has the operations every socket supports; IPSocket adds addresses; TCPSocket and UDPSocket are the two internet protocols; TCPServer accepts connections; UNIXSocket and UNIXServer do the same for local file-system sockets; Socket is the low-level, close-to-C class that exposes every option; Addrinfo describes an endpoint. Because sockets are IO objects, gets, puts, read, readpartial, write, each_line, close_write, IO.select and IO#timeout= all work on them.

Class Use it for

TCPServer

TCPServer.new(host, port) listens; accept blocks until a client connects and returns a TCPSocket; accept_nonblock and TCPServer#listen(backlog) exist for event loops.

TCPSocket

TCPSocket.new(host, port) or TCPSocket.open(host, port) { ... } connects; peeraddr, addr and remote_address describe both ends.

UDPSocket

Datagrams: bind, send(message, flags, host, port), recvfrom. No connection, no ordering, no delivery guarantee.

UNIXSocket, UNIXServer

Local inter-process communication through a path (UNIXServer.new("/tmp/x.sock")) or an anonymous pair (UNIXSocket.pair). UNIXSocket#send_io and recv_io pass file descriptors.

Socket

Socket.new(:INET, :STREAM), bind, listen, accept, setsockopt; also the class methods Socket.tcp, Socket.tcp_server_loop, Socket.unix, Socket.getaddrinfo, Socket.gethostname.

Addrinfo

An address with its family, socket type and protocol: Addrinfo.tcp("127.0.0.1", 80), ip_address, ip_port, ipv6?, connect, bind, and Addrinfo.getaddrinfo for resolution.

Socket::Option

A typed value for getsockopt and setsockopt (Socket::Option.bool(:INET, :SOCKET, :KEEPALIVE, true)).

The first example is a complete request and answer over TCP: a line-oriented server that looks up an ISBN, a client that asks, a refused connection and a read timeout. The port 0 idiom, accepting in a thread, the block form of TCPSocket.open (which closes the socket) and the exception classes are the parts to copy.

# frozen_string_literal: true

require "socket"

# A line-oriented protocol: the client sends an ISBN, the server answers with a title.
TITLES = { "9780596516178" => "The Ruby Programming Language" }.freeze

server = TCPServer.new("127.0.0.1", 0) # port 0: let the operating system pick a free port
port = server.addr[1]
server.addr.first # => "AF_INET"
server.local_address.ip_address # => "127.0.0.1"

acceptor = Thread.new do
  client = server.accept # blocks until a client connects; the GVL is released while waiting
  isbn = client.gets&.chomp
  client.puts TITLES.fetch(isbn, "unknown ISBN")
  client.close
end

TCPSocket.open("127.0.0.1", port) do |socket|
  socket.peeraddr[0] # => "AF_INET"
  socket.puts "9780596516178"
  socket.gets # => "The Ruby Programming Language\n"
end
acceptor.join

# A refused connection is an Errno::ECONNREFUSED (a SystemCallError); the port is closed now.
server.close
begin
  TCPSocket.new("127.0.0.1", port)
rescue SystemCallError => e
  puts "#{e.class}: #{e.message}" # prints Errno::ECONNREFUSED: Connection refused - connect(2) for "127.0.0.1" port ...
end

# Since Ruby 3.2 every IO has a timeout; reading past it raises IO::TimeoutError.
quiet = TCPServer.new("127.0.0.1", 0)
client = TCPSocket.new("127.0.0.1", quiet.addr[1])
peer = quiet.accept # accepted but never written to
client.timeout = 0.2
begin
  client.gets
rescue IO::TimeoutError => e
  puts "#{e.class}: #{e.message}" # prints IO::TimeoutError: Blocking operation timed out!
end
[client, peer, quiet].each(&:close)

Output (the port differs on each run):

Errno::ECONNREFUSED: Connection refused - connect(2) for "127.0.0.1" port PORT
IO::TimeoutError: Blocking operation timed out!

Four details deserve emphasis. A refused connection raises Errno::ECONNREFUSED, a subclass of SystemCallError, not of SocketError and not of IOError; every Errno::* class lives in the SystemCallError branch. IO#timeout= (Ruby 3.2) sets a per-IO limit that applies to blocking reads, writes and accept, and raises IO::TimeoutError (a subclass of IOError); without it, socket.gets waits forever for a peer that never sends anything. TCPSocket#close_write half-closes the connection, which is how a client tells a server "that is all I will send" while still reading the answer. And accept is a blocking call that releases the GVL, which is why a thread (or a fiber with a scheduler) per connection works.

The Socket class, Addrinfo, UDP and Unix sockets

Socket.tcp(host, port) { |socket| ... } is the high-level client: it resolves the name, tries the addresses, yields the connected Socket and closes it afterwards. Socket.new with bind, listen and accept is the explicit server form; accept returns the connection and the peer’s Addrinfo. The next example shows both, the two Happy Eyeballs switches, the Ruby 4.0 open_timeout: keyword, Addrinfo, Resolv, a UDP exchange and a Unix socket pair.

# frozen_string_literal: true

require "socket"
require "resolv"

# Socket.tcp_server_sockets binds every address of the host; "127.0.0.1" keeps it local.
listener = Socket.new(:INET, :STREAM)
listener.bind(Addrinfo.tcp("127.0.0.1", 0))
listener.listen(5)
port = listener.local_address.ip_port

acceptor = Thread.new do
  connection, remote = listener.accept # a Socket and the peer's Addrinfo
  remote.ip_address # => "127.0.0.1"
  connection.write(connection.readpartial(64).upcase)
  connection.close
end

# Socket.tcp with a block connects, yields the Socket and closes it afterwards.
reply = Socket.tcp("127.0.0.1", port, connect_timeout: 1) do |socket|
  socket.write("shelf")
  socket.read
end
puts reply # prints SHELF
acceptor.join

# Happy Eyeballs v2 (Ruby 3.4): Socket.tcp and TCPSocket.new race IPv6 and IPv4 attempts.
Socket.tcp_fast_fallback # => true
Socket.tcp_fast_fallback = false # the same switch as RUBY_TCP_NO_FAST_FALLBACK=1
Socket.tcp_fast_fallback # => false
Socket.tcp_fast_fallback = true

# Ruby 4.0: open_timeout bounds the whole connection phase.
client = TCPSocket.new("127.0.0.1", port, open_timeout: 1, fast_fallback: true) # the listener's backlog accepts it
client.close
listener.close

# Addrinfo describes an endpoint; Resolv resolves names in pure Ruby (hosts file first).
info = Addrinfo.tcp("127.0.0.1", 8080)
info.inspect # => "#<Addrinfo: 127.0.0.1:8080 TCP>"
info.ip_port # => 8080
info.ipv4_loopback? # => true
Resolv.getaddress("localhost").then { |ip| %w[127.0.0.1 ::1].include?(ip) } # => true

# UDP: connectionless datagrams, each recvfrom returns one whole message and the sender.
receiver = UDPSocket.new
receiver.bind("127.0.0.1", 0)
UDPSocket.new.send("ping", 0, "127.0.0.1", receiver.addr[1])
message, = receiver.recvfrom(16)
puts message # prints ping
receiver.close

# UNIXSocket.pair gives two connected local sockets without touching the network.
left, right = UNIXSocket.pair
left.write("ready")
right.readpartial(16) # => "ready"
[left, right].each(&:close)

# The error classes: name resolution failures are Socket::ResolutionError (3.3), a SocketError.
Socket::ResolutionError.ancestors.take(3) # => [Socket::ResolutionError, SocketError, StandardError]

Output:

SHELF
ping

Happy Eyeballs v2, connection timeouts and name resolution

A host name can resolve to several addresses, typically one IPv6 and one IPv4. Before Ruby 3.4, TCPSocket.new and Socket.tcp tried them one after another, so an unreachable IPv6 address made every connection wait for its full timeout before IPv4 was tried. Ruby 3.4 implements Happy Eyeballs version 2 (RFC 8305) in Socket.tcp and TCPSocket.new: name resolution runs concurrently, IPv6 is preferred, and a connection attempt to the next address starts after a short delay (the "connection attempt delay") while the earlier ones are still pending; the first to succeed wins and the others are closed. It is on by default and has three off switches:

  • Socket.tcp_fast_fallback = false turns it off for the whole process (and Socket.tcp_fast_fallback reads the current value);

  • the environment variable RUBY_TCP_NO_FAST_FALLBACK=1 does the same before the program starts;

  • the keyword fast_fallback: false disables it for a single Socket.tcp or TCPSocket.new call.

The timeouts of a TCP connection are separate settings:

Setting Meaning

connect_timeout: (Socket.tcp, TCPSocket.new)

Upper bound for the connection attempt to one address.

resolv_timeout: (Socket.tcp, TCPSocket.new)

Upper bound for name resolution.

open_timeout: (Socket.tcp, TCPSocket.new, Ruby 4.0)

Upper bound, measured from the start of the call, for the whole connection phase: no further address is tried once it expires. It cannot be combined with connect_timeout: or resolv_timeout: (ArgumentError), and passing it on Ruby 3.4 also raises ArgumentError because the keyword does not exist there.

IO#timeout= (Ruby 3.2)

Bound for each blocking read and write afterwards; raises IO::TimeoutError.

In Ruby 4.0 an expired timeout in TCPSocket.new consistently raises IO::TimeoutError; before that it could be Errno::ETIMEDOUT or IO::TimeoutError depending on the situation, and Socket.tcp can still raise Errno::ETIMEDOUT (as does the operating system itself when its own connect timeout fires), so code that handles connection timeouts rescues both.

Name resolution failures raise SocketError before Ruby 3.3 and Socket::ResolutionError from 3.3 on. Socket::ResolutionError is a subclass of SocketError, so an existing rescue SocketError keeps working, and it adds error_code (the EAI_* code of the resolver). The system resolver (getaddrinfo) blocks the calling thread; Resolv is a resolver written in Ruby, so its waits are ordinary socket waits that threads and a fiber scheduler handle like any other (the bundled resolv-replace gem makes the socket classes use it globally). Resolv.getaddress("localhost") read the hosts file in the example above (it answered with an IPv6 or an IPv4 address depending on the file); Resolv::DNS.new talks to the configured name servers.

HTTP is text over TCP

A final, instructive socket example: the HTTP exchange written by hand against the local server. This is what Net::HTTP automates: the request line and headers terminated by a blank line, the status line, the headers, and a body whose length comes from Content-Length.

# frozen_string_literal: true

require "socket"
require_relative "shelf_server"

# HTTP/1.1 is text over TCP. This is roughly what Net::HTTP does underneath.
ShelfServer.run do |server|
  TCPSocket.open("127.0.0.1", server.port) do |socket|
    socket.write("GET /books/9780596516178 HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
    status = socket.gets("\r\n").chomp
    puts status # prints HTTP/1.1 200 OK

    headers = {}
    while (line = socket.gets("\r\n")) && line != "\r\n"
      name, value = line.chomp.split(": ", 2)
      headers[name.downcase] = value
    end
    p headers # prints {"content-type" => "application/json", "content-length" => "86"}
    socket.read(headers["content-length"].to_i).bytesize # => 86
  end
end

Output:

HTTP/1.1 200 OK
{"content-type" => "application/json", "content-length" => "86"}

Writing HTTP by hand is fine for learning and for protocol-level tests, and wrong for anything else: chunked transfer encoding, compression, redirects, keep-alive, TLS and proxies are all handled by Net::HTTP.

URI

URI parses an address into components and builds one from components. URI("text") and URI.parse("text") return an object whose class depends on the scheme: URI::HTTP, URI::HTTPS, URI::FTP, URI::WS, URI::WSS, URI::LDAP, URI::LDAPS, URI::MailTo, URI::File, and URI::Generic for anything else (including relative references such as /books/1). Every URI class inherits from URI::Generic, which implements the components: scheme, userinfo, user, password, host, hostname (without the brackets of an IPv6 literal), port, path, query, fragment, opaque, plus authority, origin, request_uri (path and query, what goes on the request line) and default_port.

The next example reads and edits the components of a URL, builds URLs, resolves relative references the way a browser does (merge, the + operator, join, route_to), normalizes, and encodes and decodes query strings and components.

# frozen_string_literal: true

require "uri"

# URI() and URI.parse build an object whose class follows the scheme.
uri = URI("https://reader:placeholder@shelf.example.com:8443/books/9780596516178?format=json#details")
uri.class # => URI::HTTPS
uri.scheme # => "https"
uri.userinfo # => "reader:placeholder"
uri.host # => "shelf.example.com"
uri.port # => 8443
uri.path # => "/books/9780596516178"
uri.query # => "format=json"
uri.fragment # => "details"
uri.request_uri # => "/books/9780596516178?format=json"
uri.authority # => "shelf.example.com:8443"
uri.origin # => "https://shelf.example.com:8443"
URI("https://shelf.example.com/").port # => 443
URI("https://shelf.example.com/").eql?(URI("https://shelf.example.com:443/")) # => true

# Components are read-write; URI objects are not frozen, so copy before sharing.
copy = uri.dup
copy.path = "/loans"
copy.query = URI.encode_www_form(isbn: "9780596516178", page: 2)
copy.to_s # => "https://reader:placeholder@shelf.example.com:8443/loans?isbn=9780596516178&page=2#details"
uri.path # => "/books/9780596516178"

# Building and resolving: build, +/merge and join follow RFC 3986 reference resolution.
URI::HTTPS.build(host: "shelf.example.com", path: "/books", query: "page=2").to_s
# => "https://shelf.example.com/books?page=2"
base = URI("https://shelf.example.com/api/v1/books/")
base.merge("9780596516178").to_s # => "https://shelf.example.com/api/v1/books/9780596516178"
base.merge("../loans").to_s # => "https://shelf.example.com/api/v1/loans"
base.merge("/status").to_s # => "https://shelf.example.com/status"
URI.join("https://shelf.example.com/api/", "books").to_s # => "https://shelf.example.com/api/books"
base.route_to("https://shelf.example.com/api/v1/loans").to_s # => "../loans"
URI("HTTP://Shelf.Example.COM:80/%7Ereader").normalize.to_s # => "http://shelf.example.com/%7Ereader"

# Query strings: encode_www_form joins pairs, repeats array keys and drops the value of nil.
URI.encode_www_form(title: "Ruby & Rails", tag: %w[a b], draft: nil, n: 1)
# => "title=Ruby+%26+Rails&tag=a&tag=b&draft&n=1"
URI.decode_www_form("tag=a&tag=b&q=caf%C3%A9+au+lait") # => [["tag", "a"], ["tag", "b"], ["q", "café au lait"]]
URI.decode_www_form("q=1").to_h # => {"q" => "1"}

# Components of a URL are encoded differently: form encoding uses +, URI components use %20.
URI.encode_www_form_component("café au lait/&") # => "caf%C3%A9+au+lait%2F%26"
URI.encode_uri_component("café au lait/&") # => "caf%C3%A9%20au%20lait%2F%26"
URI.decode_www_form_component("caf%C3%A9+au+lait") # => "café au lait"
URI.decode_uri_component("caf%C3%A9+au%20lait") # => "café+au lait"

The encoding helpers are the part that most often goes wrong, because there are two encodings:

Method Space becomes Use it for

URI.encode_www_form_component(string)

A plus sign

One name or value inside a query string or an application/x-www-form-urlencoded body.

URI.encode_uri_component(string)

%20

One path segment or any component that is not form-encoded (available in Ruby 3.2 and later).

URI.encode_www_form(hash_or_pairs)

A plus sign

A whole query string from a hash or an array of pairs; an array value repeats the key, and nil gives a key with no value.

URI.decode_www_form(string)

(reverse)

An array of [name, value] pairs, which keeps repeated keys that a hash would collapse.

URI.escape and URI.unescape (the old catch-all encoders, with their encode and decode aliases) were removed in Ruby 3.0; URI.extract and URI.regexp are obsolete and print a warning under -w. Building a query with URI.encode_www_form instead of string interpolation is also what prevents injection of extra parameters through & and = in a value.

Invalid input, relative references and the parser change

# frozen_string_literal: true

require "uri"

def message_of
  yield
rescue URI::Error => e
  "#{e.class}: #{e.message}"
end

# Unescaped spaces, nil and non-ASCII characters are invalid; the message names the input.
message_of { URI("http://shelf.example.com/my books") }
# => "URI::InvalidURIError: bad URI (is not URI?): \"http://shelf.example.com/my books\""
message_of { URI.parse(nil) } # => "URI::InvalidURIError: bad URI (is not URI?): nil"
message_of { URI("http://café.example/") }
# => "URI::InvalidURIError: URI must be ascii only \"http://caf\\u00E9.example/\""

# Percent-encode the parts you control instead of the whole string.
path = "/my books/#{URI.encode_uri_component('Ruby & Rails')}"
URI::HTTP.build(host: "shelf.example.com", path: path.gsub(" ", "%20")).to_s
# => "http://shelf.example.com/my%20books/Ruby%20%26%20Rails"

# A relative reference parses, but as URI::Generic: it has no host, so it cannot be fetched.
relative = URI("/books/1")
relative.class # => URI::Generic
relative.host # => nil
relative.absolute? # => false
relative.relative? # => true
URI("shelf.example.com/books").scheme # => nil
URI("shelf.example.com:8080/books").scheme # => "shelf.example.com"

# The scheme decides the class; unknown schemes are plain URI::Generic.
URI("urn:isbn:9780596516178").class # => URI::Generic
URI("urn:isbn:9780596516178").opaque # => "isbn:9780596516178"
URI("mailto:reader@example.com").class # => URI::MailTo
URI("file:///var/shelf/books.csv").class # => URI::File
URI("ws://shelf.example.com/cable").class # => URI::WS
URI.scheme_list.keys.sort # => ["FILE", "FTP", "HTTP", "HTTPS", "LDAP", "LDAPS", "MAILTO", "WS", "WSS"]

# Host names are not normalised until you ask, and HTTP-looking classes do not validate ports.
URI("HTTP://Shelf.Example.COM/").host # => "Shelf.Example.COM"
URI("HTTP://Shelf.Example.COM/").normalize.host # => "shelf.example.com"
URI("http://shelf.example.com:99999/").port # => 99999

# Required components are checked when you build or assign: a path must start with a slash.
message_of { URI::HTTP.build(host: "shelf.example.com", path: "books") }
# => "URI::InvalidComponentError: bad component(expected absolute path component): books"

# Ruby 3.4 switched the default parser from RFC 2396 to RFC 3986. The old one is still there.
URI::DEFAULT_PARSER.class # => URI::RFC3986_Parser
URI::RFC2396_PARSER.class # => URI::RFC2396_Parser
URI::RFC2396_PARSER.extract("see http://a.example and https://b.example/x")
# => ["http://a.example", "https://b.example/x"]

What the example shows, as rules:

  • URI.parse raises URI::InvalidURIError (a URI::Error, a StandardError) for a space, for nil and for a non-ASCII host; assigning or building with a component that fails validation raises URI::InvalidComponentError. Rescue URI::Error to cover both.

  • A string without a scheme parses, to the wrong thing. URI("shelf.example.com/books") is a URI::Generic whose path is the whole string, and URI("shelf.example.com:8080/books") takes shelf.example.com as the scheme. Check uri.scheme and uri.host, or use uri.is_a?(URI::HTTP), before handing user input to Net::HTTP; URI::HTTPS is a subclass of URI::HTTP, so one test covers both.

  • Hosts are not downcased until normalize is called. == compares the normalized forms (URI("HTTP://H/x") == URI("http://h/x") is true), while eql? and hash compare the components as parsed (the same pair is not eql?); an omitted port is filled with the scheme default, which is why https://h/ and https://h:443/ are eql?.

  • The default parser changed in Ruby 3.4 from RFC 2396 to RFC 3986 (the uri gem 1.0). URI::DEFAULT_PARSER is now URI::RFC3986_Parser, which is stricter and more correct for modern URLs; the old parser is still available as URI::RFC2396_PARSER, and extract and make_regexp live there.

  • A user name and password inside the URL (https://reader:placeholder@host/) parse and are available as userinfo, but Net::HTTP does not send them as credentials (use basic_auth), and logging such a URL leaks the secret.

  • URI objects are mutable (path=, query=, scheme=, port=) and scheme= does not change the class: an URI::HTTP whose scheme is set to "ftp" is still an URI::HTTP. Duplicate before editing a URI you were handed.

  • A URL constant needs quotes: BASE = https://shelf.example.com is a syntax error. Write BASE = URI("https://shelf.example.com").freeze (freezing the URI makes it safe to share).

Net::HTTP

One-shot requests

Net::HTTP is an HTTP/1.1 client (Net::HTTP::HTTPVersion is "1.1"; there is no HTTP/2 and no HTTP/3). The class methods make a request with a new connection each time and return either the body or the response:

Call Returns

Net::HTTP.get(uri), Net::HTTP.get(host, path, port = 80)

The body as a String. No status check at all: a 404 page is returned as if it were the data.

Net::HTTP.get_response(uri)

A Net::HTTPResponse subclass: status, headers and body.

Net::HTTP.get_print(uri)

Writes the body to $stdout.

Net::HTTP.post(uri, data, headers = nil)

A response for a POST with a raw body.

Net::HTTP.post_form(uri, hash)

A response for a form POST (application/x-www-form-urlencoded).

Net::HTTP.start(host, port) with a block

Opens a session, yields the Net::HTTP object, closes the connection afterwards and returns the block’s value.

Net::HTTP.get(uri) takes a URI object, not a string (Net::HTTP.get("http://...") is read as a host name and fails). The first example fetches the catalogue, then looks at a response object: the class tells the status family (Net::HTTPOK, Net::HTTPNotFound, Net::HTTPMovedPermanently and so on, all subclasses of Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPClientError or Net::HTTPServerError), code is a String, message the reason phrase, [] a case-insensitive header lookup and value turns every non-2xx response into an exception.

# frozen_string_literal: true

require "net/http"
require "json"
require_relative "shelf_server"

ShelfServer.run do |server|
  uri = URI("#{server.base_url}/books")

  # Net::HTTP.get returns the body as a String; get_response returns the response object.
  body = Net::HTTP.get(uri)
  JSON.parse(body).map { |book| book["title"] }
  # => ["The Ruby Programming Language", "Refactoring Shelves"]

  # The same request with the host, path and port as separate arguments.
  Net::HTTP.get("127.0.0.1", "/books/9780596516178", server.port).size # => 86

  response = Net::HTTP.get_response(uri)
  response.class # => Net::HTTPOK
  response.code # => "200"
  response.message # => "OK"
  response.content_type # => "application/json"
  response["Content-Length"] # => "167"
  response.is_a?(Net::HTTPSuccess) # => true
  response.to_hash.keys.sort # => ["content-length", "content-type"]

  # A 404 is not an exception: it is a response whose class is Net::HTTPNotFound.
  missing = Net::HTTP.get_response(URI("#{server.base_url}/books/9780000000002"))
  missing.class.ancestors.take(3) # => [Net::HTTPNotFound, Net::HTTPClientError, Net::HTTPResponse]
  missing.code # => "404"

  # Response#value (alias error!) turns every non-2xx response into an exception.
  begin
    missing.value
  rescue Net::HTTPClientException => e
    puts "#{e.class}: #{e.message}" # prints Net::HTTPClientException: 404 "Not Found"
    e.response.equal?(missing) # => true
  end

  # Redirects are not followed; a 3xx is a Net::HTTPRedirection with a Location header.
  moved = Net::HTTP.get_response(URI("#{server.base_url}/old"))
  moved.class # => Net::HTTPMovedPermanently
  moved["Location"] # => "/books"
  moved.is_a?(Net::HTTPRedirection) # => true
end

Output (the puts line):

Net::HTTPClientException: 404 "Not Found"

The classes of the response hierarchy and what they mean:

Class Status codes Notes

Net::HTTPInformation

1xx

Net::HTTPContinue, Net::HTTPSwitchProtocol, Net::HTTPEarlyHints.

Net::HTTPSuccess

2xx

Net::HTTPOK (200), Net::HTTPCreated (201), Net::HTTPAccepted (202), Net::HTTPNoContent (204; the body is nil).

Net::HTTPRedirection

3xx

Net::HTTPMovedPermanently, Net::HTTPFound, Net::HTTPSeeOther, Net::HTTPNotModified, Net::HTTPTemporaryRedirect, Net::HTTPPermanentRedirect. Net::HTTP does not follow them.

Net::HTTPClientError

4xx

Net::HTTPBadRequest, Net::HTTPUnauthorized, Net::HTTPForbidden, Net::HTTPNotFound, Net::HTTPTooManyRequests, Net::HTTPUnprocessableEntity, and the rest.

Net::HTTPServerError

5xx

Net::HTTPInternalServerError, Net::HTTPBadGateway, Net::HTTPServiceUnavailable, Net::HTTPGatewayTimeout.

Net::HTTPUnknownResponse

anything else

A status the library has no class for.

Net::HTTPResponse also offers body (read once and memoized, nil for responses that have none, such as 204 and 304), read_body with a block to stream large bodies without buffering them, content_type, content_length, to_hash (header names are lowercase and each value is an array), each_header, uri (the URI that was requested, set by the URI-based calls), http_version and error! / value.

Sessions, requests and persistent connections

Net::HTTP.start with a block is the form to use for more than one request: it opens one TCP (and TLS) connection, reuses it for every request in the block (HTTP/1.1 keep-alive) and closes it when the block exits, even on an exception. Net::HTTP.new(host, port) creates the object without connecting; start without a block connects and finish closes, which makes it easy to leak a connection. The instance methods get, head, post, put, patch, delete, options, trace, and the WebDAV verbs propfind, proppatch, mkcol, copy, move, lock and unlock, return the response; request(request_object) sends a prepared request.

The request classes are Net::HTTP::Get, Head, Post, Put, Patch, Delete, Options, Trace and the WebDAV ones. A request has [] and []= for headers, body=, body_stream=, set_form_data, set_form (multipart), basic_auth, content_type=, content_length= and initheader, the hash passed to new.

# frozen_string_literal: true

require "net/http"
require "json"
require_relative "shelf_server"

# Net::HTTP.start opens the connection, yields the session and closes it afterwards.
# Several requests inside the block share one persistent (keep-alive) connection.
ShelfServer.run do |server|
  Net::HTTP.start("127.0.0.1", server.port, read_timeout: 5) do |http|
    http.started? # => true
    http.get("/books").code # => "200"
    http.get("/books/9780596516178", { "Accept" => "application/json" }).code # => "200"
    http.head("/books")["Content-Type"] # => "application/json"
  end
  server.connections # => 1

  # Without start, every one-shot call such as Net::HTTP.get connects again.
  2.times { Net::HTTP.get(URI("#{server.base_url}/books")) }
  server.connections # => 3
end

# A request object gives full control over method, headers and body.
ShelfServer.run do |server|
  uri = URI("#{server.base_url}/books")
  request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Accept" => "application/json")
  request.body = JSON.generate({ isbn: "9780134757599", title: "Refactoring", author: "Ada Reader" })
  response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
  response.code # => "201"
  JSON.parse(response.body).values_at("title", "received_type")
  # => ["Refactoring", "application/json"]

  # Ruby 4.0 (net-http 0.9): no default Content-Type for a request that has a body.
  bare = Net::HTTP::Post.new(uri)
  bare.body = JSON.generate({ title: "Anonymous" })
  bare["Content-Type"] # => nil
  response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(bare) }
  JSON.parse(response.body)["received_type"] # => nil
end

# Forms: set_form_data and Net::HTTP.post_form use application/x-www-form-urlencoded.
form = Net::HTTP::Post.new("/loans")
form.set_form_data("isbn" => "9780596516178", "member" => "Grace Stacks")
form["Content-Type"] # => "application/x-www-form-urlencoded"
form.body # => "isbn=9780596516178&member=Grace+Stacks"

# Header helpers on the request: [] is case-insensitive, basic_auth builds Authorization.
probe = Net::HTTP::Get.new("/books")
probe["Accept-Language"] = "en"
probe["accept-language"] # => "en"
probe.basic_auth("reader", "placeholder-password")
probe["Authorization"] # => "Basic cmVhZGVyOnBsYWNlaG9sZGVyLXBhc3N3b3Jk"
probe.key?("x-missing") # => false
probe.method # => "GET"
probe.path # => "/books"
probe.request_body_permitted? # => false

The server’s connections counter proves the claims: three requests in the start block used one connection, and two one-shot Net::HTTP.get calls added two more. A few points are worth stating outright:

  • Ruby 4.0 (net-http 0.9) no longer adds a Content-Type header to a request that has a body. Up to and including Ruby 3.4 (net-http 0.6), a POST or PUT with a body and no Content-Type was sent as application/x-www-form-urlencoded. The example above records what the server received: nil on 4.0, the form content type on 3.2, 3.3 and 3.4. A request without that header may be rejected or misparsed by a server; always set Content-Type when you set a body ("application/json" for JSON), and set_form_data still sets the form type for you.

  • Net::HTTP does not encode JSON, and body= expects a String. Serialize with JSON.generate (see Serialization: JSON, YAML, CSV and Marshal).

  • There is no cookie jar and no connection pool. A session is one connection and is not thread-safe; give each thread its own (see Concurrent requests).

  • In a started session, http.get(path, headers) takes the path as a String ("/books"); from a URI object use uri.request_uri (path and query). A relative URI("/books") has no host, and Net::HTTP.get(URI("/books")) fails with a confusing Failed to open TCP connection to :80.

  • Compression is automatic: Net::HTTP sends Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3 and decompresses the body (and removes Content-Encoding). If you set Accept-Encoding yourself, it assumes you want the raw bytes and does not decode (response.decode_content is false).

Timeouts, retries and keep-alive

Attribute Default Meaning

open_timeout

60 s

Time to open the TCP connection and, for HTTPS, to complete the TLS handshake. Raises Net::OpenTimeout.

read_timeout

60 s

Longest wait for each read from the socket, not a limit on the whole response. Raises Net::ReadTimeout.

write_timeout

60 s

Longest wait for each write of the request. Raises Net::WriteTimeout.

ssl_timeout

nil

The TLS session timeout (OpenSSL::SSL::SSLContext#timeout, how long a session may be resumed); the handshake itself is bounded by open_timeout.

continue_timeout

nil

How long to wait for 100 Continue after sending Expect: 100-continue.

keep_alive_timeout

2 s

How long an idle persistent connection is kept for reuse by a later request.

max_retries

1

How many times an idempotent request is repeated after a connection-level failure.

The three timeouts are set with keywords on Net::HTTP.start (open_timeout: 5, read_timeout: 10) or with the writer methods before the session starts. Because read_timeout applies to each read, a server that trickles one byte every 59 seconds is never timed out; a deadline for the whole call needs Process.clock_gettime bookkeeping around the reads, or Timeout.timeout, with the caveats in Handling failures.

Net::HTTP retries on its own. When a request whose method is GET, HEAD, PUT, DELETE, OPTIONS or TRACE fails with Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError or Timeout::Error (but not Net::OpenTimeout), it closes the socket, reconnects and sends the request again, up to max_retries times (the default is 1, so one repetition). POST and PATCH are never retried automatically because repeating them can duplicate side effects. The retry is why a read_timeout of 0.3 seconds takes about 0.6 seconds to fail, and why a server sees two connections for one failed GET.

# frozen_string_literal: true

require "net/http"
require_relative "shelf_server"

# The defaults of a fresh session: 60 s for opening, reading and writing, no TLS, one retry.
http = Net::HTTP.new("127.0.0.1", 80)
http.open_timeout # => 60
http.read_timeout # => 60
http.write_timeout # => 60
http.keep_alive_timeout # => 2
http.max_retries # => 1
http.use_ssl? # => false

ShelfServer.run do |server|
  # read_timeout bounds each wait for data, not the whole response. The /slow route sleeps 2 s.
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  begin
    Net::HTTP.start("127.0.0.1", server.port, read_timeout: 0.3) { |session| session.get("/slow") }
  rescue Net::ReadTimeout => e
    puts "#{e.class}: #{e.message}" # prints Net::ReadTimeout: Net::ReadTimeout with #<TCPSocket:(closed)>
  end
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
  elapsed.between?(0.6, 1.2) # => true (two attempts of 0.3 s: GET is idempotent and retried once)
  server.connections # => 2
end

# max_retries applies to idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) when the
# connection fails in a retryable way: a dropped connection (EOFError), a reset, a timeout.
ShelfServer.run do |server|
  [0, 1, 3].each do |retries|
    before = server.connections
    begin
      Net::HTTP.start("127.0.0.1", server.port, max_retries: retries) { |session| session.get("/drop") }
    rescue EOFError => e
      puts "max_retries #{retries}: #{e.message}, #{server.connections - before} connection(s)"
    end
  end
end

Output:

Net::ReadTimeout: Net::ReadTimeout with #<TCPSocket:(closed)>
max_retries 0: end of file reached, 1 connection(s)
max_retries 1: end of file reached, 2 connection(s)
max_retries 3: end of file reached, 4 connection(s)

Set max_retries to 0 for a client with its own retry policy (such as the one under Handling failures), so that retries are not multiplied: three application-level attempts over a client that retries once is six requests.

Redirects

A 3xx response is a Net::HTTPRedirection and nothing happens: Net::HTTP reports it and leaves the decision to the caller. The Location header may be relative (resolve it against the current URI with uri.merge), the limit on the chain must be explicit, and a client that sends credentials should not follow a redirect to another host or from https to http. OpenURI follows redirects for you under its own rules (see OpenURI).

# frozen_string_literal: true

require "net/http"
require "json"
require_relative "shelf_server"

# Net::HTTP never follows redirects by itself. This follows a bounded number of them, resolves
# a relative Location against the current URI and refuses to leave https for plain http.
def get_following(uri, limit: 5)
  raise ArgumentError, "too many redirects" if limit.zero?

  response = Net::HTTP.get_response(uri)
  return response unless response.is_a?(Net::HTTPRedirection)

  target = uri.merge(response.fetch("Location")) # Location may be relative
  raise SecurityError, "refusing https to http downgrade" if uri.scheme == "https" && target.scheme == "http"

  get_following(target, limit: limit - 1)
end

ShelfServer.run do |server|
  response = get_following(URI("#{server.base_url}/old"))
  response.code # => "200"
  response.uri.path # => "/books" (the final request URI)
  JSON.parse(response.body).size # => 2
end

301 and 302 responses to a POST are conventionally retried as a GET; 307 and 308 keep the method and body. The helper above only follows GET; a general client must pick the method by status.

Proxies, encodings and debugging

Net::HTTP.new(host, port) reads the proxy from the environment (http_proxy for plain HTTP, https_proxy for HTTPS, no_proxy for exceptions). Pass the proxy explicitly as the third to sixth arguments (Net::HTTP.new(host, port, "proxy.example", 3128, user, password)), or nil as the proxy address to ignore the environment. proxy?, proxy_address, proxy_port and proxy_from_env? report what was decided.

The body of a response is bytes. On Ruby 4.0.7 Net::HTTP returns a body that contains bytes above 127 as an ASCII-8BIT (binary) String, whatever the Content-Type charset says (an all-ASCII body may come back tagged UTF-8, so never depend on the tag either way); you decide what to do next. http.response_body_encoding = true applies the charset from the Content-Type header, and an Encoding object or a name forces one. Forgetting this is the standard cause of Encoding::CompatibilityError or mojibake when a response is interpolated into a UTF-8 string (see Encodings and Unicode).

# frozen_string_literal: true

require "net/http"
require_relative "shelf_server"

# Proxies: Net::HTTP reads http_proxy / https_proxy / no_proxy (lowercase) when you build a session
# from a host name. The proxy only matters on connect, so nothing is sent here.
ENV["http_proxy"] = "http://proxy.invalid:3128"
ENV["no_proxy"] = "localhost,127.0.0.1"
Net::HTTP.new("127.0.0.1", 8080).proxy? # => false
Net::HTTP.new("shelf.example.com", 80).proxy? # => true
Net::HTTP.new("shelf.example.com", 80).proxy_address # => "proxy.invalid"
Net::HTTP.new("shelf.example.com", 80, nil).proxy? # => false (nil as proxy address: never use one)
Net::HTTP.new("shelf.example.com", 80, "other.invalid", 8888).proxy_port # => 8888
ENV.delete("http_proxy")
ENV.delete("no_proxy")

# Encodings: the body is bytes. Without a charset the String comes back as ASCII-8BIT (BINARY).
accents = ->(_request) { [200, { "Content-Type" => "text/plain; charset=iso-8859-1" }, "café".encode("ISO-8859-1")] }
ShelfServer.run(handler: accents) do |server|
  Net::HTTP.start("127.0.0.1", server.port) do |http|
    raw = http.get("/").body
    raw.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
    raw.bytes # => [99, 97, 102, 233]
    raw.force_encoding("ISO-8859-1").encode("UTF-8") # => "café"

    http.response_body_encoding = true # take the charset parameter of Content-Type
    http.get("/").body.encoding # => #<Encoding:ISO-8859-1>
  end
end

set_debug_output writes everything that crosses the socket to any object that responds to << ($stderr, a StringIO, a Logger). It is the quickest way to see what Net::HTTP actually sent; set it before start, and never enable it in production because it records the Authorization header and the bodies.

# frozen_string_literal: true

require "net/http"
require_relative "shelf_server"

# set_debug_output logs the wire traffic to any object that responds to <<. Call it before
# starting the session, and never log it in production: it includes Authorization headers and bodies.
ShelfServer.run do |server|
  wire = +""
  http = Net::HTTP.new("127.0.0.1", server.port)
  http.set_debug_output(wire)
  http.start { http.get("/books/9780596516178", "Accept" => "application/json") }
  puts wire.lines.grep(/\A(<-|->)/).map { |line| line.gsub(":#{server.port}", ":PORT") }.first(7)
end

Output (the request line is wrapped here):

<- "GET /books/9780596516178 HTTP/1.1\r\nAccept: application/json\r\nAccept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nUser-Agent: Ruby\r\nHost: 127.0.0.1:PORT\r\n\r\n"
-> "HTTP/1.1 200 OK\r\n"
-> "Content-Type: application/json\r\n"
-> "Content-Length: 86\r\n"
-> "\r\n"
-> "{\"isbn\":\"9780596516178\",\"title\":\"The Ruby Programming Language\",\"author\":\"Ada Reader\"}"

TLS and certificate verification

http.use_ssl = true (or use_ssl: true in Net::HTTP.start, or an https URI passed to Net::HTTP.get) switches the session to TLS through OpenSSL. Verification is on by default: verify_mode defaults to OpenSSL::SSL::VERIFY_PEER and verify_hostname to true, so the server’s certificate must chain to a trusted root and must match the host that you asked for. The attributes that configure the trust and the protocol:

Attribute Purpose

verify_mode

OpenSSL::SSL::VERIFY_PEER (the default) or VERIFY_NONE.

ca_file, ca_path

A PEM file or a directory of hashed certificates to trust instead of the system store.

cert_store

An OpenSSL::X509::Store holding exactly the roots you trust (the example below builds one).

cert, key

The client certificate and its private key for mutual TLS.

min_version, max_version

Protocol bounds: OpenSSL::SSL::TLS1_2_VERSION, OpenSSL::SSL::TLS1_3_VERSION.

verify_hostname

Check that the certificate names the host (true by default); verify_callback observes or overrides each verification step.

ciphers, ssl_version

Cipher list and a fixed protocol version (prefer min_version and max_version).

The example runs a TLS server on the loopback address with a certificate that it creates in memory (no file, and nothing secret: the key exists only for the lifetime of the process). The first client trusts nothing and is refused; the second trusts exactly that certificate through a cert_store; VERIFY_NONE shows the insecure shortcut that must never be used outside a local experiment.

# frozen_string_literal: true

require "net/http"
require "openssl"
require "socket"

# A throw-away certificate authority-less setup: one self-signed certificate for 127.0.0.1,
# generated in memory (nothing is written to disk and there is nothing secret in it).
key = OpenSSL::PKey::EC.generate("prime256v1")
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=shelf-localhost")
cert.public_key = key
cert.not_before = Time.now - 60
cert.not_after = Time.now + 3600
factory = OpenSSL::X509::ExtensionFactory.new(cert, cert)
cert.add_extension(factory.create_extension("subjectAltName", "IP:127.0.0.1"))
cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
cert.sign(key, OpenSSL::Digest.new("SHA256"))

# An HTTPS server in a few lines: SSLServer wraps a TCPServer and does the handshake in accept.
context = OpenSSL::SSL::SSLContext.new
context.add_certificate(cert, key)
tcp = TCPServer.new("127.0.0.1", 0)
tls = OpenSSL::SSL::SSLServer.new(tcp, context)
tls.start_immediately = true
acceptor = Thread.new do
  loop do
    socket = tls.accept
    socket.gets("\r\n\r\n")
    socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
    socket.close
  rescue OpenSSL::SSL::SSLError, SystemCallError
    next # a client that rejected our certificate hangs up during the handshake
  end
end
port = tcp.addr[1]

# 1. use_ssl turns on TLS, and verification is on by default (VERIFY_PEER) with the system trust store.
untrusting = Net::HTTP.new("127.0.0.1", port)
untrusting.use_ssl = true
untrusting.verify_mode # => nil (VERIFY_PEER is applied when the connection is made)
begin
  untrusting.get("/")
rescue OpenSSL::SSL::SSLError => e
  # The full message starts with SSL_connect returned=1 errno=0 peeraddr=127.0.0.1:PORT state=error.
  puts e.message.split(": ").last # prints certificate verify failed (self-signed certificate)
end

# 2. Trust exactly this certificate through a cert_store (or ca_file with a PEM path).
store = OpenSSL::X509::Store.new
store.add_cert(cert)
trusting = Net::HTTP.new("127.0.0.1", port)
trusting.use_ssl = true
trusting.cert_store = store
trusting.get("/").body # => "ok"
trusting.min_version = OpenSSL::SSL::TLS1_2_VERSION
trusting.max_version = OpenSSL::SSL::TLS1_3_VERSION

# 3. Net::HTTP.start with use_ssl: true does the same in one call; https URIs set it for you.
Net::HTTP.start("127.0.0.1", port, use_ssl: true, cert_store: store) { |http| http.get("/").body } # => "ok"
URI("https://127.0.0.1:#{port}/").scheme # => "https"

# VERIFY_NONE accepts any certificate, including an attacker's: only ever a local experiment.
Net::HTTP.start("127.0.0.1", port, use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
  http.get("/").body # => "ok"
end
acceptor.kill
acceptor.join
tcp.close

Output:

certificate verify failed (self-signed certificate)

The error is an OpenSSL::SSL::SSLError (an OpenSSL::OpenSSLError, a StandardError). The usual messages are certificate verify failed (self-signed certificate), (unable to get local issuer certificate), (certificate has expired) and hostname "x" does not match the server certificate. The fixes are, in this order: use a certificate from a real authority, add the private authority to ca_file or cert_store, and, only if neither is possible, pin the exact certificate. Setting verify_mode = OpenSSL::SSL::VERIFY_NONE makes the connection encrypted but unauthenticated: anyone on the path can impersonate the server. SSL_CERT_FILE and SSL_CERT_DIR change the default trust store for the process.

OpenURI

require "open-uri" adds open and read to URI::HTTP, URI::HTTPS and URI::FTP objects and defines URI.open. It is a thin layer over Net::HTTP for reading a URL as if it were a file, with sensible defaults (it follows redirects, raises on non-2xx statuses and buffers the body). It cannot send a request body, so it is for GET only; use Net::HTTP for anything else.

  • URI("http://host/path").open { |io| io.read } and URI("http://host/path").read are the idiomatic calls. The result is extended with OpenURI::Meta: status (["200", "OK"]), content_type, charset, content_encoding, last_modified, meta (the headers), base_uri (the final URI after redirects).

  • Small bodies come back as a StringIO; a body larger than OpenURI::Buffer::StringMax (10240 bytes) is spooled to a Tempfile, so the class of the result varies with the size and a Tempfile is not removed until you close it (close!) or the garbage collector runs.

  • Header options are passed as a hash of string keys ("Accept" => "application/json"). Symbol keys are options: :read_timeout, :open_timeout, :ssl_verify_mode, :ssl_ca_cert, :ssl_min_version, :ssl_max_version, :redirect (false to disable following), :max_redirects, :proxy, :proxy_http_basic_authentication, :http_basic_authentication (["user", "password"]), :progress_proc, :content_length_proc, :encoding and :ftp_active_mode. An unknown symbol raises ArgumentError: unrecognized option.

  • Errors: OpenURI::HTTPError for a non-2xx status (the response is error.io, with status and the body), OpenURI::HTTPRedirect (a subclass, raised when redirect: false or when a redirect is refused) with the target as error.uri, and OpenURI::TooManyRedirects. Transport failures are the same Net::ReadTimeout, Errno::ECONNREFUSED and so on as for Net::HTTP. Open-uri follows up to 64 redirects by default (:max_redirects), refuses a redirect from https to http on purpose (RuntimeError: redirection forbidden), raises on a redirect loop, and rejects a URL with embedded credentials with ArgumentError: userinfo not supported (pass :http_basic_authentication instead).

# frozen_string_literal: true

require "open-uri"
require "json"
require_relative "shelf_server"

ShelfServer.run do |server|
  # URI#open (added to http and https URIs by open-uri) returns an IO-like object extended with
  # OpenURI::Meta: a StringIO for small bodies.
  URI("#{server.base_url}/books").open("Accept" => "application/json") do |io|
    io.class # => StringIO
    io.status # => ["200", "OK"]
    io.content_type # => "application/json"
    io.charset # => nil
    io.meta.keys # => ["content-type", "content-length"]
    io.base_uri.to_s.end_with?("/books") # => true
    JSON.parse(io.read).size # => 2
  end

  # URI#read is the shortcut when only the body matters (open-uri adds it to http and https URIs).
  URI.parse("#{server.base_url}/books/9780596516178").read.size # => 86

  # Redirects are followed by default; the final address is in base_uri. redirect: false raises.
  URI("#{server.base_url}/old").open { |io| io.base_uri.path } # => "/books"
  begin
    URI("#{server.base_url}/old").open(redirect: false)
  rescue OpenURI::HTTPRedirect => e
    puts "#{e.class}: #{e.message}" # prints OpenURI::HTTPRedirect: 301 Moved Permanently
    e.uri.to_s # => "http://127.0.0.1:PORT/books"
  end

  # Non-2xx statuses raise OpenURI::HTTPError; the response is still available as e.io.
  begin
    URI("#{server.base_url}/boom").open
  rescue OpenURI::HTTPError => e
    puts "#{e.class}: #{e.message}" # prints OpenURI::HTTPError: 500 Internal Server Error
    e.io.status # => ["500", "Internal Server Error"]
    JSON.parse(e.io.read) # => {"error" => "shelf collapsed"}
  end
end

# Bodies larger than OpenURI::Buffer::StringMax (10240 bytes) are spooled to a Tempfile, so
# the class of the result depends on the body size. Hooks report progress while it downloads.
big = ->(_request) { [200, { "Content-Type" => "text/csv" }, "isbn,title\n" * 2000] }
ShelfServer.run(handler: big) do |server|
  sizes = []
  io = URI("#{server.base_url}/export.csv").open(
    content_length_proc: ->(length) { sizes << length },
    progress_proc: ->(bytes) { sizes << bytes }
  )
  io.class # => Tempfile
  io.read.bytesize # => 22000
  sizes.first # => 22000
  sizes.last # => 22000
  io.close!
end

Output (the two puts lines):

OpenURI::HTTPRedirect: 301 Moved Permanently
OpenURI::HTTPError: 500 Internal Server Error

Never call URI.open with a string you do not control. URI.open(string) opens a URL if the string looks like one and falls back to Kernel#open (a local file) otherwise, so URI.open(params[:url]) lets an attacker read local files by passing a path; and a URL pointing at an internal address (http://127.0.0.1:..., cloud metadata endpoints) turns the server into a request forger (SSRF). The safe form is URI.parse(string), then check the scheme and host against an allowlist, then call .open on the resulting URI::HTTP object, which can only make a request; RuboCop’s Security/Open cop flags URI.open with a non-literal for this reason, and it is why the examples above call .open on a URI instead. Two history notes: since Ruby 3.0, requiring open-uri no longer redefines Kernel#open, so open("http://...") raises Errno::ENOENT (it is a file name) and older tutorials must be updated to URI.open; and since Ruby 4.0 Kernel#open no longer runs a command for a string that starts with a pipe (|cmd), so both traps are closed in different ways (see Processes, Signals and Shell).

Handling failures

Three families of errors

A single Net::HTTP call can fail in the address, in the transport, or in the answer, and the exceptions of the three do not share a superclass below StandardError. This listing prints the ancestry of the classes involved and then shows the pitfall that follows from it.

# frozen_string_literal: true

require "net/http"
require "open-uri"
require "openssl"
require "socket"
require_relative "shelf_server"

# The superclass chains of the errors a network call can raise (modules left out).
[
  Net::OpenTimeout, Net::HTTPBadResponse, Net::HTTPClientException, Net::HTTPFatalError, Net::HTTPError,
  Errno::ECONNREFUSED, EOFError, IO::TimeoutError, Socket::ResolutionError, OpenSSL::SSL::SSLError,
  OpenURI::HTTPError, URI::InvalidURIError
].each do |klass|
  chain = klass.ancestors.take_while { |ancestor| ancestor != StandardError }.reject { |a| a.instance_of?(Module) }
  puts chain.join(" < ")
end

# Net::HTTPExceptions (a module) is what the status-code errors share. Net::HTTPError is a
# different class that only 1xx and unknown statuses raise, so rescuing it misses a 404.
Net::HTTPClientException.include?(Net::HTTPExceptions) # => true
Net::HTTPClientException.ancestors.include?(Net::HTTPError) # => false

ShelfServer.run do |server|
  response = Net::HTTP.get_response(URI("#{server.base_url}/boom"))
  begin
    response.value
  rescue Net::HTTPError
    puts "never reached"
  rescue Net::HTTPExceptions => e
    puts "#{e.class}: #{e.message}" # prints Net::HTTPFatalError: 500 "Internal Server Error"
    e.response.code # => "500"
  end
end

Output:

Net::OpenTimeout < Timeout::Error < RuntimeError
Net::HTTPBadResponse
Net::HTTPClientException < Net::ProtoServerError < Net::ProtocolError
Net::HTTPFatalError < Net::ProtoFatalError < Net::ProtocolError
Net::HTTPError < Net::ProtocolError
Errno::ECONNREFUSED < SystemCallError
EOFError < IOError
IO::TimeoutError < IOError
Socket::ResolutionError < SocketError
OpenSSL::SSL::SSLError < OpenSSL::OpenSSLError
OpenURI::HTTPError
URI::InvalidURIError < URI::Error
Net::HTTPFatalError: 500 "Internal Server Error"

The practical consequences:

  • rescue Net::HTTPError catches almost nothing. It does not catch a refused connection (Errno::ECONNREFUSED is a SystemCallError), a timeout (Net::ReadTimeout is a Timeout::Error), a dropped connection (EOFError is an IOError), a DNS failure (SocketError), a TLS failure (OpenSSL::SSL::SSLError), and not even the status-code errors raised by response.value (Net::HTTPClientException for 4xx, Net::HTTPFatalError for 5xx and Net::HTTPRetriableError for 3xx). The common supertype of those three is the module Net::HTTPExceptions, which rescue accepts, and their superclass is Net::ProtocolError. Net::HTTPError itself is raised only for 1xx and unrecognized statuses. Tutorials that wrap a request in rescue Net::HTTPError and expect it to cover network failures are wrong.

  • Net::HTTPServerException, the old name for the 4xx exception, is a deprecated constant alias of Net::HTTPClientException (using it prints a warning under -w); the name was misleading, since a 4xx is a client error.

  • Net::OpenTimeout, Net::ReadTimeout and Net::WriteTimeout are subclasses of Timeout::Error, which is a subclass of RuntimeError. A rescue Timeout::Error therefore also catches a timeout from any other source, and a bare rescue RuntimeError catches them too.

  • IO::TimeoutError (from socket reads with IO#timeout=) is an IOError, not a Timeout::Error.

  • The status is not an exception unless you ask: Net::HTTP.get_response and http.get return a 404 or a 500 as a response, so check response.is_a?(Net::HTTPSuccess) or call response.value.

  • Do not rescue Exception or rescue => e with a blanket Exception around network code: it swallows Interrupt, SystemExit and NoMemoryError. rescue => e (a StandardError) already covers every class above.

The error classes grouped by what to do about them:

Failure Classes Typical policy

Bad address

URI::InvalidURIError, URI::InvalidComponentError, ArgumentError

A programming or input error: do not retry; report to the caller.

Name resolution

Socket::ResolutionError (3.3), SocketError

Transient or configuration; retry a few times, then fail.

Connect and reset

Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::EPIPE, Errno::ETIMEDOUT, EOFError

Transient; retry with back-off. Errno::ECONNREFUSED for a service that is starting up resolves itself.

Timeouts

Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IO::TimeoutError

Retry only if the request is idempotent (an expired read may mean the server acted on it).

TLS

OpenSSL::SSL::SSLError

Usually permanent (wrong certificate, expired, hostname): do not retry.

Malformed reply

Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError

A broken server or proxy; limited retry.

4xx

Net::HTTPClientException (or a Net::HTTPClientError response)

The request is wrong: do not retry; 429 and 408 are the exceptions (honor Retry-After).

5xx

Net::HTTPFatalError (or a Net::HTTPServerError response)

Retry 502, 503 and 504; 500 only if the operation is idempotent.

A client that separates the failures

The robust shape is a small client that translates the three families into two application-level errors: one for "the request did not produce a usable answer" and one for "the answer says no". Callers then rescue the application errors without knowing about sockets, and the original exception stays available as cause. The client below also retries, with the policy from the table: transport errors and 5xx are retried with a growing pause, everything else is final. NETWORK_ERRORS is the complete list of transport-level classes, because rescue accepts an array splatted with *.

# frozen_string_literal: true

require "net/http"
require "openssl"
require "json"

module Shelf
  class RemoteError < StandardError; end

  # The request never produced a usable HTTP response (DNS, TCP, TLS, timeout, broken reply).
  class NetworkError < RemoteError; end

  # The server answered with 4xx or 5xx.
  class HttpError < RemoteError
    attr_reader :status

    def initialize(status, message = "HTTP #{status}")
      super(message)
      @status = status
    end
  end

  class RemoteCatalog
    # Everything that can go wrong below the HTTP status line. Order does not matter for
    # rescue lists, but every entry must be here: Net::HTTPError is NOT one of them.
    NETWORK_ERRORS = [
      SocketError, SystemCallError, IOError, # Errno::ECONNREFUSED/ECONNRESET/EPIPE, EOFError, IO::TimeoutError
      Timeout::Error,                        # Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
      OpenSSL::SSL::SSLError,
      Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
    ].freeze

    def initialize(base_url, attempts: 3, pause: 0.05)
      @base = URI(base_url)
      @attempts = attempts
      @pause = pause
    end

    def books = JSON.parse(get("/books"))

    def book(isbn) = JSON.parse(get("/books/#{isbn}"))

    def get(path)
      tries = 0
      begin
        tries += 1
        request(path)
      rescue NetworkError, HttpError => e
        raise unless retryable?(e) && tries < @attempts

        sleep(@pause * tries) # linear back-off; add jitter when many clients retry together
        retry
      end
    end

    private

    def retryable?(error) = error.is_a?(NetworkError) || error.status >= 500

    def request(path)
      response = Net::HTTP.start(@base.host, @base.port, open_timeout: 1, read_timeout: 1) do |http|
        http.get(path)
      end
      return response.body if response.is_a?(Net::HTTPSuccess)

      raise HttpError.new(response.code.to_i, "#{response.code} #{response.message}")
    rescue *NETWORK_ERRORS => e
      raise NetworkError, "#{e.class}: #{e.message}"
    end
  end
end
# frozen_string_literal: true

require_relative "remote_catalog"
require_relative "shelf_server"

def attempt
  yield
rescue Shelf::RemoteError => e
  cause = e.cause ? " (cause: #{e.cause.class})" : ""
  puts "#{e.class}: #{e.message}#{cause}"
end

ShelfServer.run do |server|
  catalog = Shelf::RemoteCatalog.new(server.base_url)
  catalog.books.map { |book| book["title"] } # => ["The Ruby Programming Language", "Refactoring Shelves"]
  catalog.book("9780596516178")["author"] # => "Ada Reader"

  attempt { catalog.book("9780000000002") } # a 404 is final: only 5xx and transport errors are retried
  attempt { catalog.get("/boom") }
  attempt { catalog.get("/drop") }
  server.connections # => 12 (1 + 1 + 1 for the 404, 3 for /boom, 3 attempts x 2 for /drop)
end

# Nothing listens on this port any more: the failure is a NetworkError wrapping Errno::ECONNREFUSED.
closed_port = ShelfServer.run(&:port)
attempt { Shelf::RemoteCatalog.new("http://127.0.0.1:#{closed_port}").books }

Output (the port differs on each run):

Shelf::HttpError: 404 Not Found
Shelf::HttpError: 500 Internal Server Error
Shelf::NetworkError: EOFError: end of file reached (cause: EOFError)
Shelf::NetworkError: Errno::ECONNREFUSED: Failed to open TCP connection to 127.0.0.1:PORT (Connection refused - connect(2) for "127.0.0.1" port PORT) (cause: Errno::ECONNREFUSED)

Notes on the design:

  • Net::HTTP.start(..., open_timeout: 1, read_timeout: 1) sets the timeouts the client needs, and each of the three attempts for /drop opens two connections (the attempt plus the automatic idempotent retry), which is the 12 connections that the demo counts. A client with its own retries should pass max_retries: 0.

  • The retry keyword re-runs the begin block; the counter is a local variable outside it, so it survives. The pause grows with the attempt number (a linear back-off); real clients add random jitter so that many clients do not retry in lockstep, and honor Retry-After for 429 and 503.

  • Do not retry a non-idempotent request blindly. A POST that raised Net::ReadTimeout may have been executed by the server. Retry it only if the API offers an idempotency key, which you send as a header and the server uses to deduplicate.

  • raise NetworkError, "..." inside a rescue clause sets cause automatically; e.cause is how a logger or a debugger gets back to the Errno::ECONNREFUSED underneath.

  • Only translate what you can name. A NoMethodError from a bug in the client is not a network error and must not be hidden in NetworkError.

Timeout.timeout is not a network timeout

Timeout.timeout(5) { Net::HTTP.get(uri) } looks like a deadline for the whole call, and it is, in a limited way: it raises Timeout::Error into the block from another thread at an arbitrary point, which can interrupt ensure clauses, leave a connection half-read and corrupt the state of anything the block was changing. Prefer the timeouts that the library itself checks (open_timeout, read_timeout, write_timeout, IO#timeout=, open_timeout: on sockets), and use Timeout.timeout only as an outer safety net for code that has no timeout of its own. The details are in Threads and the GVL.

Concurrent requests

Waiting for the network releases the GVL, so threads overlap the waits and several requests take about as long as the slowest. Each thread needs its own Net::HTTP session, because a session holds one socket and a response in flight; sharing one would interleave bytes. Thread#value joins the thread and returns the block’s value, and re-raises its exception in the caller, so a failure in any request is not silently lost.

# frozen_string_literal: true

require "net/http"
require_relative "shelf_server"

# Each request takes 0.3 s on the server. Network waits release the GVL, so threads overlap them.
slow = lambda do |request|
  sleep 0.3
  [200, { "Content-Type" => "text/plain" }, request.path]
end

ShelfServer.run(handler: slow) do |server|
  paths = %w[/a /b /c /d]
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  # One Net::HTTP session per thread: a session is not safe to share between threads.
  bodies = paths.map do |path|
    Thread.new { Net::HTTP.start("127.0.0.1", server.port) { |http| http.get(path).body } }
  end.map(&:value)

  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
  bodies.join(",") # => "/a,/b,/c,/d"
  elapsed.between?(0.3, 0.9) # => true (sequentially it would take 1.2 s)
  server.connections # => 4
end

For a bounded number of workers, put the work in a Queue and start a fixed number of threads (the pattern is in Threads and the GVL); one thread per request is fine for a handful and a bad idea for thousands. For very high fan-out, the non-blocking route is a fiber scheduler with an HTTP client that supports it (Fibers and the Fiber Scheduler). When the work after the download is CPU-heavy, it does not run in parallel on CRuby, which is the subject of Ractors.

Beyond the standard library

Net::HTTP is deliberately low-level. The Ruby community maintains higher-level clients that add the pieces that it lacks, such as a middleware stack, JSON helpers, connection pools, HTTP/2, parallel requests and a friendlier API. They are named here only so that you can recognize them; this page does not cover their APIs.

  • Faraday, HTTParty, HTTP (http.rb), Typhoeus, Excon and httpx are the widely used client libraries.

  • net-http-persistent adds connection pooling to Net::HTTP.

  • Async::HTTP belongs to the Async ecosystem built on the fiber scheduler.

  • On the server side, applications use a Rack server such as Puma, Falcon or Unicorn rather than writing sockets.

Installing any of them is a bundle add away (see RubyGems and Bundler); evaluate the same things as for any dependency: maintenance, TLS defaults, timeouts, and whether its exceptions are mapped to a hierarchy you can rescue.

Security checklist for network code

  • Leave certificate verification on (VERIFY_PEER, verify_hostname); use cert_store or ca_file for a private authority.

  • Set open_timeout, read_timeout and write_timeout on every client; the 60-second defaults hang a worker for a minute per call.

  • Never pass untrusted input to URI.open, Net::HTTP.get or a raw TCPSocket.new without validating scheme, host and port against an allowlist (SSRF); remember that redirects can change the host and that DNS can change the address between the check and the connection.

  • Build query strings with URI.encode_www_form and paths with URI.encode_uri_component, never by interpolating raw values.

  • Do not log full URLs with credentials, Authorization headers or the output of set_debug_output.

  • Do not follow redirects blindly: limit the count, and drop credentials when the host changes.

  • Prefer HTTPS URLs; a http URL can be altered by anyone on the path.

  • Treat response bodies as untrusted data; parse JSON with JSON.parse, never with eval or Marshal.load (see Security).

Where older material is outdated

Online examples and older books about Ruby networking are still useful for the ideas, and several details have changed. Check these in code you inherit:

  • rescue Net::HTTPError as the catch-all for a failed request (it is not; see Three families of errors), and Net::HTTPServerException for 4xx statuses (deprecated alias of Net::HTTPClientException).

  • An unquoted URL on the right of an assignment (URL = https://...), which is a syntax error; the value is a string or a URI.

  • open("http://...") and open("|command"): open-uri stopped redefining Kernel#open in Ruby 3.0 and the pipe form was removed in Ruby 4.0; use URI.parse(url).open and Open3 or IO.popen instead.

  • URI.escape and URI.unescape (removed in Ruby 3.0) and URI.extract / URI.regexp (obsolete; URI::RFC2396_PARSER holds them now).

  • Code that relies on the automatic Content-Type: application/x-www-form-urlencoded for a request body, which Ruby 4.0 removed.

  • require "net/http" together with require "uri" (harmless; net/http loads uri) and require "socket" for Addrinfo (still needed).

  • WEBrick as the way to start a server in a test: removed from the standard library in Ruby 3.0. The examples on this page show the standard-library alternative, TCPServer plus a thread.

  • Timeout.timeout as the only protection for a hung request; the library timeouts and IO#timeout= (Ruby 3.2) exist now.

  • The assumption that name resolution failures are plain SocketError (they are Socket::ResolutionError from Ruby 3.3, which is still a SocketError), and that IPv6 trouble means long connection delays (Ruby 3.4 races IPv6 and IPv4).

Version notes

Version Change

3.0

open-uri no longer redefines Kernel#open (use URI.open or URI#open); URI.encode and URI.escape removed; WEBrick removed from the standard library.

3.2

IO#timeout and IO#timeout= (raise IO::TimeoutError); URI.encode_uri_component and URI.decode_uri_component (present in 3.2 and later); Data (used by the example server).

3.3

Socket::ResolutionError, a subclass of SocketError, for name resolution failures.

3.4

Happy Eyeballs v2 in Socket.tcp and TCPSocket.new (fast_fallback:, Socket.tcp_fast_fallback, RUBY_TCP_NO_FAST_FALLBACK); the default URI parser is RFC 3986 (uri 1.0); net-http 0.6.0 removes constants that had been deprecated since 2012 (Net::HTTPInformationCode, Net::HTTPSuccessCode and similar); the Windows-only net-http-sspi library is extracted from the Ruby repository.

4.0

Socket.tcp and TCPSocket.new accept open_timeout:, and TCPSocket.new consistently raises IO::TimeoutError on a user-specified timeout; net-http 0.9.1 stops adding a default Content-Type to requests with a body; uri 1.1.1, open-uri 0.5.0, openssl 4.0.2; Kernel#open with a leading pipe is removed.