Net::HTTP is not your API client

Often times in a ruby project that needs to interact with an internal- or external API for which a Gem is not available you see code like this:

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

When this code makes it to production, it usually is one of the first pieces that breaks.

After a while you have your exception tracker littered with Net::ReadTimeout, Net::OpenTimeout errors and depending on the data returned by the particular API a number of parsing exceptions (good ‘ol JSON::ParserError comes to mind). Where did this go wrong?

During a rushed development process we naturally tend to focus most of our time making things work and too little time taking into account various error-cases that will happen in the environment our software exists in.

Additionally we assume that our code only fails deterministically based on our inputs.

In development this also often times seems to be the case when we take a look at our piece of code again:

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

In reality, there is a number of ways this piece of code breaks in production:

The fact about this piece of code is though: Net::HTTP is a bare metal piece of the ruby standard-library to interact with HTTP and will not take care of this for you.

Making a vanilla Net::HTTP request more resilient requires a decent amount of boilerplate:

Looking at the documentation we can see how to handle redirects:

# From the ruby stdlib docs at: https://ruby-doc.org/stdlib-2.7.0/libdoc/net/http/rdoc/Net/HTTP.html
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

In order to handle timeouts there is various settings you could tweak:

# Taken from https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts#nethttp
http = Net::HTTP.new(host, port)
http.open_timeout = 1
http.read_timeout = 1
http.write_timeout = 1

or just go ahead and go nuclear on timeouts and rate-limiting (please don’t do this):

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

a less extreme solution is to back off after a couple of times:

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

Now I am a fan of killing your dependencies. But if you are not writing a piece of software that is supposed to be used as a library, are pressed for time and do not have the Net::HTTP API mastered yet - please go ahead and use Faraday.

require 'faraday_middleware'

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

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

  faraday.use FaradayMiddleware::FollowRedirects
  faraday.adapter Faraday.default_adapter
end

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

This piece of code follows redirects and uses exponential backoff and retry for:

This piece of code is enough to get most timeout- and network-related errors handled, follow redirects and even works with basic rate-limiting based on the Retry-After header.

Faraday has most use-cases that you will ever have in an API handled.

Faraday 1.0.0 has exactly one external dependency with no additional transitive dependencies. The faraday_middleware gem only depends on Faraday itself.

Especially if you are working on a mature Sinatra or Rails project, chances are high you have Faraday already included somewhere in your dependency graph.

Tl;dr: Don’t roll your own solution for issues that have been solved over and over again and for which stable libraries are available. Only drop down to the lowest level of abstraction when it is really necessary to get rid of your dependencies.