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:
- the server redirects the request, because the API domain or endpoint moved
- the network misbehaves
- the server is overloaded
- the endpoint is slow to respond
- you get rate limited
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.