rubyguides

HTTP clients in Ruby: Net::HTTP, Faraday, and HTTParty

When you need HTTP clients in Ruby, you usually end up choosing between Net::HTTP, Faraday, and HTTParty. Ruby gives you all three paths, and each one suits a different style of project.

This guide compares the three main approaches and shows you when to use which. The short version is simple: start with the standard library when you want no extra dependencies, move to Faraday when you want composable middleware, and use HTTParty when you want a lightweight, expressive interface.

Key takeaways

  • Net::HTTP ships with Ruby and is a good default when you want complete control with no extra gem.
  • Faraday is ideal when you want retry, logging, authentication, and JSON handling to live in middleware.
  • HTTParty is the quickest option for small scripts and straightforward API calls.
  • All three libraries can work well in production if you set timeouts, handle errors, and keep the request flow easy to read.

If you are choosing between them for the first time, think about the team as much as the code. A short script can tolerate a concise one-liner, but a larger application benefits from a client that makes retries, headers, and error handling obvious.

Net::HTTP: the standard library workhorse

Net::HTTP ships with Ruby. No gems needed, no dependencies to manage. It handles everything from simple GET requests to persistent connections with custom headers and timeouts.

It is a strong choice when you want to see every step of the request in one place. That makes it a little more verbose than the alternatives, but the verbosity is also a benefit when you need to reason about SSL, redirects, and failure modes.

Basic GET Request

require 'net/http'
require 'uri'

uri = URI('https://api.github.com/repos/ruby/ruby')
response = Net::HTTP.get_response(uri)

puts response.code       # => "200"
puts response['content-type']  # => "application/json; charset=utf-8"
puts response.body

This is the most direct form of HTTP work in Ruby. You build the URI, ask for a response, and then inspect the returned object yourself. That is useful when you want minimal abstraction and do not mind a few extra lines in exchange for explicit control. The get_response shortcut handles the connection lifecycle for you, which keeps the code short when a single request is all you need.

For HTTPS, enable SSL:

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER

response = http.get(uri)

When you handle HTTPS explicitly, the connection setup becomes obvious. That clarity matters in debugging, especially if a service starts failing because of certificate, proxy, or timeout problems. Explicit SSL configuration also gives you control over certificate verification, which is useful when working with internal services that use self-signed certificates in development.

Setting Headers

Pass a hash to add headers:

request = Net::HTTP::Get.new(uri)
request['Authorization'] = 'Bearer your-token'
request['Accept'] = 'application/json'

response = http.request(request)

Headers are where API clients start to feel real. Authentication tokens, content negotiation, and custom version headers all belong here, and keeping them in one place makes a request easier to review later. Grouping headers at the request level also means you can swap out authentication tokens or content types without touching the connection object. Keeping headers close to the request they belong to also helps when you are debugging, because you can see the full request shape in one glance. That locality makes it easier to spot mismatched content types or missing auth tokens before the request even goes out.

Timeouts

http.open_timeout = 5   # seconds to wait for a connection
http.read_timeout = 10  # seconds to wait for a response

response = http.get(uri)

Timeouts are worth setting even in small scripts. Without them, a request that hangs can pin a worker thread or keep a background job alive far longer than you expect. A quick timeout also keeps your application responsive under load, because a stuck request will not hold up the caller indefinitely.

Persistent Connections

Reuse the same connection across multiple requests:

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.start

# Connection stays open across these requests
10.times do
  response = http.get('/users/1')
  puts response.code
end

http.finish

Persistent connections help when you are making several requests to the same host in a row. They cut down on connection setup overhead, which makes a noticeable difference in scripts that paginate through API data or process multiple endpoints back to back.

POST with Body

require 'json'

uri = URI('https://httpbin.org/post')
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ key: 'value' })

response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(request)
end

puts response.code  # => "200"

POST requests are where you usually start to care about request bodies, headers, and response codes at the same time. Keeping the code explicit makes it easier to see what gets sent and how the server answered.

Faraday: composable middleware

Faraday wraps HTTP clients behind a uniform interface. Its real power is the middleware stack: you can add retry logic, JSON encoding, authentication, and logging as separate components.

That separation is useful when different teams touch the same client code. You can keep the request builder small and push cross-cutting concerns into middleware, where they are easier to reuse and test.

Installation

# Gemfile
gem 'faraday'

Once the gem is in your Gemfile, you can start building connections. Faraday organises requests around a connection object that holds the base URL, middleware stack, and adapter in one place. This keeps the per-request code concise and makes it easy to swap out the underlying HTTP library later if your needs change.

Basic Usage

require 'faraday'

conn = Faraday.new(url: 'https://api.github.com') do |f|
  f.response :json
  f.adapter Faraday.default_adapter
end

response = conn.get('/repos/ruby/ruby')
puts response.body['description']

This style reads well because the connection configuration sits in one place. When you revisit the code later, you can see the base URL, response parsing, and adapter choice without hunting through helper methods.

Middleware

Middleware pieces compose to add behavior:

conn = Faraday.new(url: 'https://api.github.com') do |f|
  f.response :json
  f.request :retry, max: 3, interval: 0.5
  f.adapter Faraday.default_adapter
end

Common middleware gems:

  • faraday-retry. Automatic retries with backoff
  • faraday-follow_redirects. Follow HTTP redirects
  • faraday-multipart. File uploads
  • faraday-oauth. OAuth authentication

Middleware is the feature that usually tips people toward Faraday. It lets you compose small, focused behaviours instead of building one large wrapper class that does everything.

Adapters

Faraday delegates to an underlying adapter. Swap adapters without changing your code:

# Uses Net::HTTP by default, but you can choose:
Faraday.new adapter: :patron    # libcurl bindings
Faraday.new adapter: :httpclient # Java-based HTTP client

The adapter abstraction is one of Faraday’s strongest features. You can start with the default Net::HTTP adapter for development and switch to a faster one like Typhoeus or Patron in production without rewriting any request code. The adapter swap is invisible to the rest of your application, so you can benchmark different backends without touching any of the request logic.

Custom Headers

conn = Faraday.new(url: 'https://api.github.com') do |f|
  f.headers['Authorization'] = 'token YOUR_TOKEN'
  f.headers['Accept'] = 'application/vnd.github.v3+json'
end

response = conn.get('/user')

Faraday becomes especially nice when an application needs several APIs with different behaviours. You can keep a separate connection per service, reuse the same response middleware, and change only the details that differ.

HTTParty: one-line requests

HTTParty is the quickest way to get going. Make a GET request in a single line:

require 'httparty'

response = HTTParty.get('https://api.github.com/repos/ruby/ruby')
puts response.code
puts response.parsed_response['description']

HTTParty is a good fit when you want the call site to look as small as possible. That clarity works well in scripts, prototypes, and glue code, though you will usually outgrow it once the request logic needs more structure.

Class-Based Setup

Register a base URL and default options on a class:

require 'httparty'

class GitHub
  include HTTParty
  base_uri 'https://api.github.com'
  headers 'Accept' => 'application/vnd.github.v3+json'
end

# Now each call uses the base URI
repos = GitHub.get('/users/ruby/repos')
puts repos.first['name']

The class-based pattern keeps base URLs and headers in one place, which is useful if you are making a handful of related requests. It is still simple enough for quick work, but it gives you a little more reuse than a bare one-liner. Setting up the class once and reusing it across your script keeps the request sites tidy and the base URI in a single, visible place. That kind of setup-once, use-everywhere pattern is what keeps HTTParty scripts readable even as they grow, and it pushes the boilerplate into one spot where future maintainers will find it easily.

Authentication

# Basic Auth
response = HTTParty.get(
  'https://api.github.com/user',
  basic_auth: { username: 'user', password: 'token' }
)

# API Token
response = HTTParty.get(
  'https://api.github.com/user',
  headers: { 'Authorization' => 'token YOUR_TOKEN' }
)

Authentication is where one-liners can start to look a little cramped. If you find yourself repeating the same options on every call, that is usually a sign that you want a shared wrapper or a different client altogether. When the same auth hash appears on every call, extracting it into a shared setup method or a class-based client keeps the call sites clean.

Query Parameters

response = HTTParty.get(
  'https://api.github.com/search/code',
  query: { q: 'language:ruby', sort: 'stars' }
)

Query parameters are another place where HTTParty stays easy to read. You pass a hash, and the library handles the encoding, which keeps the request line compact. The query hash is a small convenience, but it adds up when you are making several calls with different parameter sets, because the pattern stays the same every time.

Which HTTP client should you use?

Use Net::HTTP when you want the smallest dependency footprint and you do not mind writing a few extra lines. Use Faraday when you expect retries, middleware, or reusable connection settings to matter. Use HTTParty when you want something fast to read and fast to write.

The bigger the application gets, the more valuable the middleware or connection abstraction becomes. The smaller the task, the more attractive a short and direct request helper becomes.

Error handling

All three libraries raise exceptions on network failures. Handle them consistently:

require 'net/http'
require 'uri'

def fetch(url)
  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.open_timeout = 5
  http.read_timeout = 10

  http.start
  response = http.get(uri)
  http.finish

  case response
  when Net::HTTPSuccess
    response.body
  when Net::HTTPRedirection
    fetch(response['Location'])  # follow redirect
  else
    raise "Unexpected response: #{response.code}"
  end
rescue Net::OpenTimeout, Net::ReadTimeout => e
  puts "Timeout: #{e.message}"
  nil
rescue SocketError, Errno::ECONNREFUSED => e
  puts "Connection error: #{e.message}"
  nil
end

That block shows the full error-handling lifecycle for a single Net::HTTP call: open the connection, make the request, check the response class, and rescue network-level failures. It is explicit, which helps when you want to handle each failure mode differently.

With Faraday, add the middleware and handle exceptions after the fact:

conn = Faraday.new(url: 'https://api.github.com') do |f|
  f.response :raise_error
  f.adapter Faraday.default_adapter
end

begin
  response = conn.get('/repos/ruby/ruby')
rescue Faraday::Error => e
  puts "Request failed: #{e.message}"
end

The important part is not the exact rescue list. It is the habit of deciding what should happen when the network is slow, the server is down, or the response is not what you expected. Clear error handling keeps HTTP code predictable, which matters more than library choice in many applications.

Retry logic

Net::HTTP manual retry

def fetch_with_retry(url, max_attempts: 3)
  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  max_attempts.times do |attempt|
    begin
      http.open_timeout = 5
      http.read_timeout = 10
      http.start
      return http.get(uri)
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      puts "Attempt #{attempt + 1} failed: #{e.message}"
      sleep 2 ** attempt  # exponential backoff
    ensure
      http.finish if http.started?
    end
  end

  raise "Failed after #{max_attempts} attempts"
end

Faraday retry middleware

Faraday handles retries through its middleware stack, which is more declarative than the manual approach. You specify the retry policy once in the connection setup, and every request through that connection inherits the same retry behaviour. This eliminates the boilerplate of wrapping each call site in a retry loop and keeps the policy consistent across your application.

require 'faraday'
require 'faraday/retry'

conn = Faraday.new(url: 'https://api.github.com') do |f|
  f.response :raise_error
  f.request :retry,
    max: 3,
    interval: 0.5,
    backoff_factor: 2,
    exceptions: [Faraday::Error::TimeoutError, Errno::ECONNREFUSED]
  f.adapter Faraday.default_adapter
end

Common mistakes

  • Skipping timeouts and letting a request hang forever.
  • Treating every non-200 response as the same failure.
  • Building a custom wrapper before the request patterns actually repeat.
  • Forgetting to keep headers and authentication settings in one shared place.

Frequently asked questions

Is Net::HTTP good enough for production?

Yes. It is capable and battle-tested. The tradeoff is that you write more setup code yourself, so the request logic can become noisy if your application makes many different calls.

When does Faraday make more sense than HTTParty?

Faraday makes more sense when you need middleware, retries, logging, or multiple adapters. HTTParty is usually better when the request patterns are simple and the code should stay compact.

Should I always use a gem instead of the standard library?

No. If your project only needs a couple of requests, Net::HTTP can be the cleanest option because it keeps the dependency list small and the moving parts obvious.

Quick comparison

Pick the client that matches the shape of the work. Net::HTTP gives you direct control, Faraday gives you a middleware pipeline, and HTTParty gives you a small, pleasant interface for common API calls. Once you choose one, the real win comes from setting timeouts, handling failures, and keeping the request code easy to understand a few months later.

If you revisit this guide later, start with the section that best matches your current pain point: low-level control, reusable middleware, or quick one-liners. That makes the choice less abstract and helps you move from examples to actual code faster.

LibraryBest For
Net::HTTPScripts, one-off requests, and minimal dependencies
FaradayComposable HTTP layers, testing, and reusable clients
HTTPartyQuick API calls, prototyping, and class-based clients

For a small script or DevOps automation, Net::HTTP is fine. When you need to add logging, retries, or authentication, Faraday pays off. Reach for HTTParty when you want to set up a class-based API client in minutes.

Conclusion

The best HTTP client is the one that matches the shape of the work. Net::HTTP keeps the dependency list tiny, Faraday gives you reusable middleware, and HTTParty keeps short request code easy to scan.

If you are still deciding, start with the smallest option that fits the job. You can always add structure later when the request patterns repeat often enough to justify it.

See Also