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 Close

GET 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_NONE in 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 default

If you get an OpenSSL::SSL::SSLError in 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.start with 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 timeoutsopen_timeout for the TCP connection time limit, read_timeout for the response waiting limit; without them your app can hang forever.
  • Reuse connections for consecutive requests — open one Net::HTTP.start and make many requests inside; far more efficient than opening a new connection each time.
  • Don’t disable SSL verificationVERIFY_NONE is 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 checking response.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_body with a block for large downloads — stream the response body to disk without loading the whole file into memory.

← Previous: URI   Next: Tempfile →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact