Michael Wallbaum

Net::HTTP is not your API client

In a Ruby project that has to talk to an internal or external API with no gem available for it, you often see this:

uri = URI("https://example.com/api/v1/whateva")
Net::HTTP.get_response(uri)

When that reaches production it is usually one of the first things to break.

After a while your exception tracker is full of Net::ReadTimeout and Net::OpenTimeout, plus whatever parsing errors the API’s responses provoke - JSON::ParserError being the old favourite. Where did this go wrong?

Under time pressure we spend most of our attention on making the thing work and very little on the error cases that the environment will produce whether we consider them or not. We also quietly assume our code only fails as a function of its inputs.

In development that even looks true. In production, this one line breaks in at least five ways:

Net::HTTP is a bare-metal part of the standard library. It will not handle any of that for you, and it is not trying to.

What robustness costs by hand

Redirects, from the standard library’s own documentation:

def fetch(uri_str, limit = 10)
  # You should choose a better exception.
  raise ArgumentError, "too many HTTP redirects" if limit == 0

  response = Net::HTTP.get_response(URI(uri_str))

  case response
  when Net::HTTPSuccess then
    response
  when Net::HTTPRedirection then
    location = response["location"]
    warn "redirected to #{location}"
    fetch(location, limit - 1)
  else
    response.value
  end
end

Timeouts are three separate settings:

http = Net::HTTP.new(host, port)
http.open_timeout = 1
http.read_timeout = 1
http.write_timeout = 1

You could also go nuclear, which you should not:

uri = URI("https://example.com/api/v1/whateva")
response = Net::HTTP.get_response(uri) rescue retry

Backing off after a few attempts is the least you should do:

RETRY_LIMIT = 3
attempts = 0

begin
  uri = URI("https://example.com/api/v1/whateva")
  response = Net::HTTP.get_response(uri)
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
  attempts += 1
  retry if attempts <= RETRY_LIMIT
  raise
end

The 2020 version of this example had rescue retry left on the request line, a leftover from the nuclear option above it. That made the outer rescue unreachable and retried forever on any StandardError, which is the opposite of what the surrounding code was demonstrating. It is fixed above, and it is a fair illustration of the point: the boilerplate is easy to get subtly wrong.

Or use the library

I am a fan of killing your dependencies. But unless you are writing something meant to be used as a library, are pressed for time, and have not mastered the Net::HTTP API - use Faraday.

# Gemfile
#   gem "faraday"
#   gem "faraday-retry"
#   gem "faraday-follow_redirects"

require "faraday"
require "faraday/retry"
require "faraday/follow_redirects"

connection = Faraday.new("https://example.com") do |f|
  f.request :json
  f.response :json, content_type: /\bjson$/

  f.request :retry,
    max: 3,
    interval: 1,
    backoff_factor: 2,
    retry_statuses: [ 429, 503 ]

  f.response :follow_redirects
end

connection.get("/api/v1/whateva")

That follows redirects and applies exponential backoff to network errors and timeouts, 503s from an overloaded server, and 429 rate limiting - the last of which respects Retry-After.

Note what changed since this was written. In Faraday 1.x the above needed the faraday_middleware gem. Faraday 2.0 moved JSON request and response handling into Faraday itself and re-released the rest as independent gems, and faraday_middleware is deprecated and will not support Faraday 2. retry and follow_redirects are now faraday-retry and faraday-follow_redirects. You also no longer need to name the adapter; net_http is the default.

Faraday covers most of what you will ever need from an API client - authentication, retry with backoff, parallel requests, and enough middleware to build logging and error handling on top.

TL;DR: do not roll your own solution to problems that have been solved repeatedly and have stable libraries. Drop to the lowest level of abstraction only when you genuinely need to shed the dependency.