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:
- Server redirects your request (i.e. API Domain or endpoint changed)
- Network issues
- Server overloaded by too many requests
- Endpoint is slow to process your result
- Rate limiting
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:
- Network issues and Timeouts
- 503 Service unavailable server overload errors
- 429 Too many requests rate limiting errors
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.
- Authentication
- Retry with exponential backoff
- Running requests in parallel and many more Middlewares to setup robust error handling, logging and parsing.
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.