Net::HTTP #
When a Ruby application needs to communicate with an external API, fetch a web page, or send data to another server, Net::HTTP is the first choice available without installing any gems. This library provides a comprehensive HTTP client — GET, POST, PUT, DELETE, PATCH, all HTTP methods available, with HTTPS support, custom headers, timeouts, redirects, and file uploads. Net::HTTP is indeed more verbose than gems like Faraday or HTTParty, but it’s zero-dependency and present in every Ruby installation. Understanding it also helps you understand the abstractions built on top of it. This article covers Net::HTTP usage patterns from the simplest to fairly complex ones.
The Simplest Request #
Ruby provides several shortcuts for the most basic HTTP requests — useful for quick scripts but limited for production use.
require "net/http"
require "uri"
# Net::HTTP.get — simplest, returns the body as a string
body = Net::HTTP.get(URI.parse("https://api.github.com/users/ruby"))
# => JSON string
# Net::HTTP.get_response — returns a complete response object
response = Net::HTTP.get_response(URI.parse("https://httpbin.org/get"))
response.code # => "200"
response.message # => "OK"
response.body # => body string
response["content-type"] # => "application/json"
# Shortcut with separate host and path
Net::HTTP.get("httpbin.org", "/get")
# => body string
The Standard Usage Pattern #
For more serious usage, the Net::HTTP.start pattern gives full control over the connection.
require "net/http"
require "uri"
require "json"
uri = URI.parse("https://api.github.com/repos/ruby/ruby")
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
request = Net::HTTP::Get.new(uri)
request["Accept"] = "application/json"
request["User-Agent"] = "MyApp/1.0"
response = http.request(request)
case response
when Net::HTTPSuccess
JSON.parse(response.body)
when Net::HTTPRedirection
puts "Redirect to: #{response["location"]}"
else
raise "HTTP Error: #{response.code} #{response.message}"
end
end
sequenceDiagram
participant App as Ruby Application
participant HTTP as Net::HTTP
participant Server as Server
App->>HTTP: Net::HTTP.start(host, port)
HTTP->>Server: TCP Connection
App->>HTTP: request = Net::HTTP::Get.new(uri)
App->>HTTP: http.request(request)
HTTP->>Server: HTTP GET /path
Server-->>HTTP: HTTP Response
HTTP-->>App: Net::HTTPResponse
App->>App: response.code / response.body
HTTP->>Server: Connection CloseGET Requests #
require "net/http"
require "uri"
require "json"
# GET with query parameters
def get_users(role: nil, page: 1)
uri = URI.parse("https://api.example.com/v1/users")
params = { page: page }
params[:role] = role if role
uri.query = URI.encode_www_form(params)
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
request["Accept"] = "application/json"
response = http.request(request)
raise "Error: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body, symbolize_names: true)
end
end
# GET with custom headers
def download_file(url, destination_path)
uri = URI.parse(url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
request = Net::HTTP::Get.new(uri)
http.request(request) do |response|
raise "Download failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
File.open(destination_path, "wb") do |file|
response.read_body { |chunk| file.write(chunk) }
end
end
end
end
download_file("https://example.com/data.csv", "/tmp/data.csv")
POST Requests #
require "net/http"
require "uri"
require "json"
# POST with a JSON body
def create_user(name:, email:, role: "user")
uri = URI.parse("https://api.example.com/v1/users")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
request.body = JSON.generate({ name: name, email: email, role: role })
response = http.request(request)
case response.code.to_i
when 201
JSON.parse(response.body, symbolize_names: true)
when 422
error = JSON.parse(response.body)
raise "Validation failed: #{error["message"]}"
else
raise "Error #{response.code}: #{response.message}"
end
end
end
# POST with form data (application/x-www-form-urlencoded)
def login(username, password)
uri = URI.parse("https://auth.example.com/login")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Post.new(uri)
request.set_form_data({ username: username, password: password })
# set_form_data automatically sets Content-Type: application/x-www-form-urlencoded
response = http.request(request)
raise "Login failed" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
end
PUT, PATCH, and DELETE #
require "net/http"
require "uri"
require "json"
# PUT — replace a resource completely
def update_user(id, data)
uri = URI.parse("https://api.example.com/v1/users/#{id}")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Put.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
request.body = JSON.generate(data)
http.request(request)
end
end
# PATCH — update some fields
def patch_user(id, fields)
uri = URI.parse("https://api.example.com/v1/users/#{id}")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Patch.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
request.body = JSON.generate(fields)
response = http.request(request)
JSON.parse(response.body, symbolize_names: true)
end
end
# DELETE
def delete_user(id)
uri = URI.parse("https://api.example.com/v1/users/#{id}")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
response = http.request(request)
response.is_a?(Net::HTTPSuccess)
end
end
HTTPS and SSL Configuration #
require "net/http"
require "openssl"
uri = URI.parse("https://api.example.com/data")
# Standard HTTPS — SSL certificate verification (default and safe)
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
# http.verify_mode defaults to OpenSSL::SSL::VERIFY_PEER
response = http.get(uri.path)
end
# More detailed SSL configuration
Net::HTTP.start(uri.host, uri.port,
use_ssl: true,
verify_mode: OpenSSL::SSL::VERIFY_PEER,
ca_file: "/etc/ssl/certs/ca-certificates.crt"
) do |http|
response = http.get(uri.path)
end
Never use
verify_mode: OpenSSL::SSL::VERIFY_NONEin production. This disables SSL certificate verification and makes the connection vulnerable to man-in-the-middle attacks — anyone could intercept or modify the data being sent.# ANTI-PATTERN: disabling SSL verification http.verify_mode = OpenSSL::SSL::VERIFY_NONE # ✗ DON'T in production! # CORRECT: always verify, debug the certificate if there's a problem http.verify_mode = OpenSSL::SSL::VERIFY_PEER # ✓ the safe defaultIf you get an
OpenSSL::SSL::SSLErrorin development, the common causes are self-signed or expired certificates. Update your system’s certificate store, don’t disable verification.
Timeouts #
Without timeouts, a request that never gets a response will hang your application forever. Always set reasonable timeouts.
require "net/http"
uri = URI.parse("https://api.example.com/data")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
# open_timeout — the maximum time to open a TCP connection
http.open_timeout = 5 # 5 seconds
# read_timeout — the maximum time to wait for a response after sending the request
http.read_timeout = 30 # 30 seconds
# write_timeout — the maximum time to send the request body (Ruby 2.6+)
http.write_timeout = 10 # 10 seconds
begin
response = http.get(uri.path)
rescue Net::OpenTimeout => e
puts "Connection timeout: couldn't connect to the server within 5 seconds"
rescue Net::ReadTimeout => e
puts "Read timeout: the server didn't respond within 30 seconds"
end
end
# Or set them at initialization
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 5
http.read_timeout = 30
http.start do |h|
h.get(uri.path)
end
Handling Responses #
require "net/http"
# Net::HTTP response class hierarchy
# Net::HTTPResponse
# Net::HTTPSuccess (2xx)
# Net::HTTPOK (200)
# Net::HTTPCreated (201)
# Net::HTTPNoContent (204)
# Net::HTTPRedirection (3xx)
# Net::HTTPMovedPermanently (301)
# Net::HTTPFound (302)
# Net::HTTPClientError (4xx)
# Net::HTTPBadRequest (400)
# Net::HTTPUnauthorized (401)
# Net::HTTPForbidden (403)
# Net::HTTPNotFound (404)
# Net::HTTPServerError (5xx)
# Net::HTTPInternalServerError (500)
def request_with_error_handling(uri_string)
uri = URI.parse(uri_string)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 5, read_timeout: 30) do |http|
response = http.get(uri.request_uri)
case response
when Net::HTTPSuccess
response.body
when Net::HTTPUnauthorized
raise "Unauthorized: check the API token"
when Net::HTTPForbidden
raise "Forbidden: no access"
when Net::HTTPNotFound
nil # resource not found, return nil
when Net::HTTPTooManyRequests
retry_after = response["retry-after"]&.to_i || 60
raise "Rate limited. Try again in #{retry_after} seconds."
when Net::HTTPServerError
raise "Server error #{response.code}: #{response.message}"
when Net::HTTPRedirection
# Follow the redirect manually
location = response["location"]
request_with_error_handling(location)
else
raise "Unexpected response: #{response.code} #{response.message}"
end
end
rescue Net::OpenTimeout
raise "Connection timeout"
rescue Net::ReadTimeout
raise "Read timeout"
rescue SocketError => e
raise "Network error: #{e.message}"
end
File Uploads (Multipart Form) #
require "net/http"
# Upload a file with multipart/form-data
def upload_file(url, file_path, field_name: "file", extra_params: {})
uri = URI.parse(url)
file = File.open(file_path, "rb")
boundary = "----RubyMultipart#{SecureRandom.hex(8)}"
# Build the multipart body manually
body_parts = []
# Extra fields
extra_params.each do |key, value|
body_parts << "--#{boundary}\r\n"
body_parts << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
body_parts << "#{value}\r\n"
end
# The file field
filename = File.basename(file_path)
body_parts << "--#{boundary}\r\n"
body_parts << "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n"
body_parts << "Content-Type: application/octet-stream\r\n\r\n"
body_parts << file.read
body_parts << "\r\n--#{boundary}--\r\n"
body = body_parts.join
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
request["Content-Length"] = body.bytesize.to_s
request.body = body
http.request(request)
end
ensure
file&.close
end
Connection Reuse and Pooling #
Opening a new TCP connection for every request is expensive. Use Net::HTTP.start with a block or a persistent connection for consecutive requests to the same host.
require "net/http"
# ANTI-PATTERN: opening a new connection for every request
def fetch_many_users_slowly(ids)
ids.map do |id|
response = Net::HTTP.get_response(URI.parse("https://api.example.com/users/#{id}"))
JSON.parse(response.body)
# Opens and closes a TCP connection for every ID!
end
end
# CORRECT: reuse the connection for all requests
def fetch_many_users(ids)
uri = URI.parse("https://api.example.com")
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: 5, read_timeout: 10) do |http|
ids.map do |id|
request = Net::HTTP::Get.new("/users/#{id}")
request["Authorization"] = "Bearer #{ENV["API_TOKEN"]}"
response = http.request(request)
JSON.parse(response.body, symbolize_names: true)
end
end
# The TCP connection is opened once, used for all requests, closed once
end
An API Client Wrapper Class #
For production use, wrapping Net::HTTP in your own class is far more maintainable.
require "net/http"
require "uri"
require "json"
class HttpClient
class Error < StandardError
attr_reader :code, :body
def initialize(msg, code: nil, body: nil)
super(msg)
@code = code
@body = body
end
end
class NotFound < Error; end
class Unauthorized < Error; end
class ServerError < Error; end
class Timeout < Error; end
DEFAULT_TIMEOUT = { open: 5, read: 30 }.freeze
DEFAULT_HEADERS = {
"Accept" => "application/json",
"Content-Type" => "application/json"
}.freeze
def initialize(base_url, headers: {}, timeout: {})
@base_uri = URI.parse(base_url)
@headers = DEFAULT_HEADERS.merge(headers)
@timeout = DEFAULT_TIMEOUT.merge(timeout)
end
def get(path, params: {})
uri = build_uri(path, params)
execute(Net::HTTP::Get.new(uri))
end
def post(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Post.new(uri)
request.body = JSON.generate(body)
execute(request)
end
def put(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Put.new(uri)
request.body = JSON.generate(body)
execute(request)
end
def patch(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Patch.new(uri)
request.body = JSON.generate(body)
execute(request)
end
def delete(path)
uri = build_uri(path)
execute(Net::HTTP::Delete.new(uri))
end
private
def build_uri(path, params = {})
uri = @base_uri.dup
uri.path = File.join(uri.path.chomp("/"), path)
uri.query = URI.encode_www_form(params) unless params.empty?
uri
end
def execute(request)
@headers.each { |k, v| request[k] = v }
http = Net::HTTP.new(@base_uri.host, @base_uri.port)
http.use_ssl = @base_uri.scheme == "https"
http.open_timeout = @timeout[:open]
http.read_timeout = @timeout[:read]
response = http.request(request)
handle_response(response)
rescue Net::OpenTimeout, Net::ReadTimeout => e
raise Timeout, "Request timeout: #{e.message}"
rescue SocketError => e
raise Error, "Network error: #{e.message}"
end
def handle_response(response)
case response
when Net::HTTPSuccess
return nil if response.body.nil? || response.body.empty?
JSON.parse(response.body, symbolize_names: true)
when Net::HTTPUnauthorized
raise Unauthorized.new("Unauthorized", code: 401, body: response.body)
when Net::HTTPNotFound
raise NotFound.new("Not found", code: 404, body: response.body)
when Net::HTTPServerError
raise ServerError.new("Server error #{response.code}", code: response.code.to_i, body: response.body)
else
raise Error.new("HTTP #{response.code}: #{response.message}", code: response.code.to_i, body: response.body)
end
end
end
# Usage
client = HttpClient.new("https://api.example.com/v1",
headers: { "Authorization" => "Bearer #{ENV["API_TOKEN"]}" },
timeout: { open: 3, read: 15 }
)
begin
users = client.get("/users", params: { role: "admin" })
new_profile = client.post("/users", body: { name: "Alice", email: "[email protected]" })
client.patch("/users/1", body: { active: false })
client.delete("/users/99")
rescue HttpClient::Unauthorized
puts "Token expired, needs refresh"
rescue HttpClient::NotFound
puts "Resource not found"
rescue HttpClient::Timeout
puts "Request timeout, try again later"
rescue HttpClient::Error => e
puts "HTTP Error #{e.code}: #{e.message}"
end
Summary #
Net::HTTP.startwith a block — the standard pattern giving full control over the connection and automatically closing it after the block.use_ssl: uri.scheme == "https"— the idiomatic way to enable HTTPS; auto-detected from the URI scheme.- Always set timeouts —
open_timeoutfor the TCP connection time limit,read_timeoutfor the response waiting limit; without them your app can hang forever.- Reuse connections for consecutive requests — open one
Net::HTTP.startand make many requests inside; far more efficient than opening a new connection each time.- Don’t disable SSL verification —
VERIFY_NONEis only for local debugging with self-signed certificates, never in production.- Handle responses by class — use
when Net::HTTPSuccess,when Net::HTTPNotFound, and so on, rather than checkingresponse.code == "200"as a string.- Wrap in your own class for production — a wrapper class makes the code cleaner, adds centralized error handling, and eases replacing the HTTP library later.
response.read_bodywith a block for large downloads — stream the response body to disk without loading the whole file into memory.