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 |
|---|---|---|
|
|
Standard library extension; |
|
|
Default gem. |
|
|
Default gem. |
|
|
Default gem (1.1.1 in 4.0.7). |
|
|
Default gem (net-http 0.9.1 in 4.0.7). |
|
|
Default gem (0.5.0). |
|
|
Default gem (4.0.2); needs the OpenSSL library on the machine. |
|
Clients for other protocols. |
Bundled gems (they are installed with Ruby but not default gems, so a Bundler project lists them in its |
WEBrick |
A pure-Ruby HTTP server library. |
Removed from the standard library in Ruby 3.0 and not installed with 4.0 ( |
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
Source: Ruby 4.0 documentation: TCPServer and Data
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 |
|---|---|
|
|
|
|
|
Datagrams: |
|
Local inter-process communication through a path ( |
|
|
|
An address with its family, socket type and protocol: |
|
A typed value for |
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!
Source: Ruby 4.0 documentation: TCPServer, TCPSocket and IO
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
Source: Ruby 4.0 documentation: Socket, Addrinfo, UDPSocket, UNIXSocket and Resolv
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 = falseturns it off for the whole process (andSocket.tcp_fast_fallbackreads the current value); -
the environment variable
RUBY_TCP_NO_FAST_FALLBACK=1does the same before the program starts; -
the keyword
fast_fallback: falsedisables it for a singleSocket.tcporTCPSocket.newcall.
The timeouts of a TCP connection are separate settings:
| Setting | Meaning |
|---|---|
|
Upper bound for the connection attempt to one address. |
|
Upper bound for name resolution. |
|
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 |
|
Bound for each blocking read and write afterwards; raises |
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"
Source: Ruby 4.0 documentation: URI, URI::Generic, URI::HTTP and URI::HTTPS
The encoding helpers are the part that most often goes wrong, because there are two encodings:
| Method | Space becomes | Use it for |
|---|---|---|
|
A plus sign |
One name or value inside a query string or an |
|
|
One path segment or any component that is not form-encoded (available in Ruby 3.2 and later). |
|
A plus sign |
A whole query string from a hash or an array of pairs; an array value repeats the key, and |
|
(reverse) |
An array of |
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"]
Source: Ruby 4.0 documentation: URI::InvalidURIError, URI and URI::Generic
What the example shows, as rules:
-
URI.parseraisesURI::InvalidURIError(aURI::Error, aStandardError) for a space, forniland for a non-ASCII host; assigning or building with a component that fails validation raisesURI::InvalidComponentError. RescueURI::Errorto cover both. -
A string without a scheme parses, to the wrong thing.
URI("shelf.example.com/books")is aURI::Genericwhose path is the whole string, andURI("shelf.example.com:8080/books")takesshelf.example.comas the scheme. Checkuri.schemeanduri.host, or useuri.is_a?(URI::HTTP), before handing user input toNet::HTTP;URI::HTTPSis a subclass ofURI::HTTP, so one test covers both. -
Hosts are not downcased until
normalizeis called.==compares the normalized forms (URI("HTTP://H/x") == URI("http://h/x")is true), whileeql?andhashcompare the components as parsed (the same pair is noteql?); an omitted port is filled with the scheme default, which is whyhttps://h/andhttps://h:443/areeql?. -
The default parser changed in Ruby 3.4 from RFC 2396 to RFC 3986 (the
urigem 1.0).URI::DEFAULT_PARSERis nowURI::RFC3986_Parser, which is stricter and more correct for modern URLs; the old parser is still available asURI::RFC2396_PARSER, andextractandmake_regexplive there. -
A user name and password inside the URL (
https://reader:placeholder@host/) parse and are available asuserinfo, butNet::HTTPdoes not send them as credentials (usebasic_auth), and logging such a URL leaks the secret. -
URI objects are mutable (
path=,query=,scheme=,port=) andscheme=does not change the class: anURI::HTTPwhose scheme is set to"ftp"is still anURI::HTTP. Duplicate before editing a URI you were handed. -
A URL constant needs quotes:
BASE = https://shelf.example.comis a syntax error. WriteBASE = URI("https://shelf.example.com").freeze(freezing theURImakes 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 |
|---|---|
|
The body as a |
|
A |
|
Writes the body to |
|
A response for a |
|
A response for a form |
|
Opens a session, yields the |
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 |
|---|---|---|
|
1xx |
|
|
2xx |
|
|
3xx |
|
|
4xx |
|
|
5xx |
|
|
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-Typeheader to a request that has a body. Up to and including Ruby 3.4 (net-http 0.6), aPOSTorPUTwith a body and noContent-Typewas sent asapplication/x-www-form-urlencoded. The example above records what the server received:nilon 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 setContent-Typewhen you set a body ("application/json"for JSON), andset_form_datastill sets the form type for you. -
Net::HTTPdoes not encode JSON, andbody=expects aString. Serialize withJSON.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 aString("/books"); from aURIobject useuri.request_uri(path and query). A relativeURI("/books")has no host, andNet::HTTP.get(URI("/books"))fails with a confusingFailed to open TCP connection to :80. -
Compression is automatic:
Net::HTTPsendsAccept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3and decompresses the body (and removesContent-Encoding). If you setAccept-Encodingyourself, it assumes you want the raw bytes and does not decode (response.decode_contentisfalse).
Timeouts, retries and keep-alive
| Attribute | Default | Meaning |
|---|---|---|
|
60 s |
Time to open the TCP connection and, for HTTPS, to complete the TLS handshake. Raises |
|
60 s |
Longest wait for each read from the socket, not a limit on the whole response. Raises |
|
60 s |
Longest wait for each write of the request. Raises |
|
|
The TLS session timeout ( |
|
|
How long to wait for |
|
2 s |
How long an idle persistent connection is kept for reuse by a later request. |
|
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 |
|---|---|
|
|
|
A PEM file or a directory of hashed certificates to trust instead of the system store. |
|
An |
|
The client certificate and its private key for mutual TLS. |
|
Protocol bounds: |
|
Check that the certificate names the host (true by default); |
|
Cipher list and a fixed protocol version (prefer |
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 }andURI("http://host/path").readare the idiomatic calls. The result is extended withOpenURI::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 thanOpenURI::Buffer::StringMax(10240 bytes) is spooled to aTempfile, so the class of the result varies with the size and aTempfileis 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(falseto disable following),:max_redirects,:proxy,:proxy_http_basic_authentication,:http_basic_authentication(["user", "password"]),:progress_proc,:content_length_proc,:encodingand:ftp_active_mode. An unknown symbol raisesArgumentError: unrecognized option. -
Errors:
OpenURI::HTTPErrorfor a non-2xx status (the response iserror.io, withstatusand the body),OpenURI::HTTPRedirect(a subclass, raised whenredirect: falseor when a redirect is refused) with the target aserror.uri, andOpenURI::TooManyRedirects. Transport failures are the sameNet::ReadTimeout,Errno::ECONNREFUSEDand so on as forNet::HTTP. Open-uri follows up to 64 redirects by default (:max_redirects), refuses a redirect fromhttpstohttpon purpose (RuntimeError: redirection forbidden), raises on a redirect loop, and rejects a URL with embedded credentials withArgumentError: userinfo not supported(pass:http_basic_authenticationinstead).
# 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
Source: Ruby 4.0 documentation: OpenURI and OpenURI::OpenRead
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"
Source: Ruby 4.0 documentation: Net::ProtocolError, Net::HTTPClientException, Net::HTTPFatalError and Net::HTTPError
The practical consequences:
-
rescue Net::HTTPErrorcatches almost nothing. It does not catch a refused connection (Errno::ECONNREFUSEDis aSystemCallError), a timeout (Net::ReadTimeoutis aTimeout::Error), a dropped connection (EOFErroris anIOError), a DNS failure (SocketError), a TLS failure (OpenSSL::SSL::SSLError), and not even the status-code errors raised byresponse.value(Net::HTTPClientExceptionfor 4xx,Net::HTTPFatalErrorfor 5xx andNet::HTTPRetriableErrorfor 3xx). The common supertype of those three is the moduleNet::HTTPExceptions, whichrescueaccepts, and their superclass isNet::ProtocolError.Net::HTTPErroritself is raised only for 1xx and unrecognized statuses. Tutorials that wrap a request inrescue Net::HTTPErrorand expect it to cover network failures are wrong. -
Net::HTTPServerException, the old name for the 4xx exception, is a deprecated constant alias ofNet::HTTPClientException(using it prints a warning under-w); the name was misleading, since a 4xx is a client error. -
Net::OpenTimeout,Net::ReadTimeoutandNet::WriteTimeoutare subclasses ofTimeout::Error, which is a subclass ofRuntimeError. Arescue Timeout::Errortherefore also catches a timeout from any other source, and a barerescue RuntimeErrorcatches them too. -
IO::TimeoutError(from socket reads withIO#timeout=) is anIOError, not aTimeout::Error. -
The status is not an exception unless you ask:
Net::HTTP.get_responseandhttp.getreturn a 404 or a 500 as a response, so checkresponse.is_a?(Net::HTTPSuccess)or callresponse.value. -
Do not
rescue Exceptionorrescue => ewith a blanketExceptionaround network code: it swallowsInterrupt,SystemExitandNoMemoryError.rescue => e(aStandardError) already covers every class above.
The error classes grouped by what to do about them:
| Failure | Classes | Typical policy |
|---|---|---|
Bad address |
|
A programming or input error: do not retry; report to the caller. |
Name resolution |
|
Transient or configuration; retry a few times, then fail. |
Connect and reset |
|
Transient; retry with back-off. |
Timeouts |
|
Retry only if the request is idempotent (an expired read may mean the server acted on it). |
TLS |
|
Usually permanent (wrong certificate, expired, hostname): do not retry. |
Malformed reply |
|
A broken server or proxy; limited retry. |
4xx |
|
The request is wrong: do not retry; |
5xx |
|
Retry |
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
Source: Ruby 4.0 documentation: Net::HTTP and Exception
# 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/dropopens 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 passmax_retries: 0. -
The
retrykeyword re-runs thebeginblock; 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 honorRetry-Afterfor429and503. -
Do not retry a non-idempotent request blindly. A
POSTthat raisedNet::ReadTimeoutmay 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 arescueclause setscauseautomatically;e.causeis how a logger or a debugger gets back to theErrno::ECONNREFUSEDunderneath. -
Only translate what you can name. A
NoMethodErrorfrom a bug in the client is not a network error and must not be hidden inNetworkError.
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
Source: Ruby 4.0 documentation: Thread and Net::HTTP
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); usecert_storeorca_filefor a private authority. -
Set
open_timeout,read_timeoutandwrite_timeouton every client; the 60-second defaults hang a worker for a minute per call. -
Never pass untrusted input to
URI.open,Net::HTTP.getor a rawTCPSocket.newwithout 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_formand paths withURI.encode_uri_component, never by interpolating raw values. -
Do not log full URLs with credentials,
Authorizationheaders or the output ofset_debug_output. -
Do not follow redirects blindly: limit the count, and drop credentials when the host changes.
-
Prefer HTTPS URLs; a
httpURL can be altered by anyone on the path. -
Treat response bodies as untrusted data; parse JSON with
JSON.parse, never withevalorMarshal.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::HTTPErroras the catch-all for a failed request (it is not; see Three families of errors), andNet::HTTPServerExceptionfor 4xx statuses (deprecated alias ofNet::HTTPClientException). -
An unquoted URL on the right of an assignment (
URL = https://...), which is a syntax error; the value is a string or aURI. -
open("http://...")andopen("|command"):open-uristopped redefiningKernel#openin Ruby 3.0 and the pipe form was removed in Ruby 4.0; useURI.parse(url).openandOpen3orIO.popeninstead. -
URI.escapeandURI.unescape(removed in Ruby 3.0) andURI.extract/URI.regexp(obsolete;URI::RFC2396_PARSERholds them now). -
Code that relies on the automatic
Content-Type: application/x-www-form-urlencodedfor a request body, which Ruby 4.0 removed. -
require "net/http"together withrequire "uri"(harmless;net/httploadsuri) andrequire "socket"forAddrinfo(still needed). -
WEBrickas 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,TCPServerplus a thread. -
Timeout.timeoutas the only protection for a hung request; the library timeouts andIO#timeout=(Ruby 3.2) exist now. -
The assumption that name resolution failures are plain
SocketError(they areSocket::ResolutionErrorfrom Ruby 3.3, which is still aSocketError), and that IPv6 trouble means long connection delays (Ruby 3.4 races IPv6 and IPv4).
Version notes
| Version | Change |
|---|---|
3.0 |
|
3.2 |
|
3.3 |
|
3.4 |
Happy Eyeballs v2 in |
4.0 |
|
References
-
Ruby 4.0 documentation: Socket, BasicSocket, IPSocket, TCPSocket, TCPServer, UDPSocket, UNIXSocket, UNIXServer, Addrinfo and Socket::ResolutionError
-
Net::HTTP, Net::HTTPRequest, Net::HTTPResponse, Net::HTTPHeader and Net::HTTPGenericRequest
-
OpenURI, OpenURI::OpenRead, OpenSSL::SSL::SSLContext and Timeout
-
Ruby 3.0.0 NEWS, 3.2.0, 3.3.0, 3.4.0 and 4.0.0
-
RFC 8305, "Happy Eyeballs Version 2: Better Connectivity Using Concurrency", rfc-editor.org; RFC 3986, "Uniform Resource Identifier (URI): Generic Syntax", rfc-editor.org; RFC 9110, "HTTP Semantics", rfc-editor.org