skip to content

A Ruby service calls a weather API through Net::HTTP GET with read_timeout set to 5, yet a stalled call takes about 10 seconds to raise Net::ReadTimeout. Why, and what would you change?

level: seniorimportance: should knowfreq 30%

answer

  1. the library tries again quietly
  2. max_retries defaults to 1
  3. idempotent methods only
  4. read_timeout is per read, not total
  5. why teams reach for a client gem

basics

~20 s

Net::HTTP retries an idempotent request such as GET once by default (max_retries is 1) after Net::ReadTimeout, so two 5-second waits elapse. Set max_retries to 0 and handle retries yourself, and remember read_timeout bounds each read, not the total.

solid answer

~40 s

`Net::HTTP#max_retries` defaults to 1. When a request whose method is idempotent (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) fails with `Net::ReadTimeout`, `IOError`, `EOFError`, `Errno::ECONNRESET`, `Errno::ECONNABORTED`, `Errno::EPIPE`, `Errno::ETIMEDOUT`, an SSL error or `Timeout::Error`, Net::HTTP closes the socket, reconnects and sends it again, so a stalled GET waits two full read timeouts. POST and PATCH are not retried, and `Net::OpenTimeout` is re-raised at once. On top of that, `read_timeout` limits each individual read, so a server trickling bytes can keep one call alive far longer. The fix is `max_retries: 0` in the `start` options, your own retry with backoff where the call is safe, and a total deadline, which is one reason teams adopt a client gem.

code

ruby · 9 lines
ruby
require "net/http"

uri = URI("https://api.example.com/v1/forecast?city=Oslo")

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
                      open_timeout: 1, read_timeout: 2,
                      max_retries: 0) do |http|
  http.request(Net::HTTP::Get.new(uri))
end

go deeper

for a junior

Recall that Net::HTTP may send a GET twice on its own and that read_timeout is not a limit on the whole call.

for a middle

Explain max_retries, the idempotent method list, which errors trigger a retry, and why read_timeout restarts after each chunk.

for a senior

Diagnose doubled latency from the retry, set max_retries to 0 with explicit backoff, add a total deadline and a fallback for the dependency.

for a principal

Decide when Net::HTTP plus a thin wrapper is enough and when pooled, instrumented client gems justify the dependency across services.

## What actually happens The surprise has two sources, both in `Net::HTTP`'s transport code rather than in your service. **Automatic retry.** Every `Net::HTTP` object has **`max_retries`**, initially **1**. When a request fails with one of a fixed set of transport errors, Net::HTTP checks two things: whether it has retries left, and whether the request's method is in its idempotent list. If both hold, it closes the socket, opens a new connection and sends the request again. - Methods retried: `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`, `TRACE`. - Methods never retried: `POST`, `PATCH` and anything else outside that list. - Errors that trigger it: `Net::ReadTimeout`, `IOError`, `EOFError`, `Errno::ECONNRESET`, `Errno::ECONNABORTED`, `Errno::EPIPE`, `Errno::ETIMEDOUT`, `OpenSSL::SSL::SSLError` and `Timeout::Error`. - Errors that do not: `Net::OpenTimeout` is rescued first and re-raised immediately. - Once your block has started receiving the response body, the retry is disabled for that request, so a download is never restarted halfway. For the weather call: the first attempt waits 5 seconds and hits `Net::ReadTimeout`; Net::HTTP reconnects and waits another 5 seconds; only then does the exception reach your code. Roughly 10 seconds, plus a connect. **Per-read timeout.** **`read_timeout`** is the limit for **one** wait on the socket, not for the whole response. Net::HTTP reads in chunks and restarts the clock after each chunk arrives. A server that sends a few bytes every four seconds never trips a five-second `read_timeout`, and the call can last as long as the server keeps dribbling. There is no built-in total deadline. ## Why the retry exists, and when it hurts The retry is there mainly for connections that go bad between requests: a reused keep-alive socket that the server has already closed fails on the first write or read, and resending an idempotent request on a fresh connection is usually harmless. For a dependency that is **slow** rather than disconnected, the same logic doubles the time your caller waits and doubles the load on a service that is already struggling. Note too that "idempotent" is a promise made by the HTTP method, not verified by Net::HTTP. A `PUT` or `DELETE` endpoint that is not really idempotent in your API gets retried all the same. ## What to change 1. **Make retries explicit.** Pass `max_retries: 0` to `Net::HTTP.start` (it calls `max_retries=` like any other option) or assign `http.max_retries = 0`. Then retry in your own code where it is safe, with backoff and a cap, and log each attempt. 2. **Bound the whole call.** Decide a total budget, for example 3 seconds for a forecast, and enforce it outside the per-read timeout: a client gem with an overall deadline, or a wall-clock wrapper (the `Timeout` module has its own hazards, which belong with threads). 3. **Keep `open_timeout` short** so an unreachable host fails fast; it is never retried. 4. **Degrade gracefully.** Serve a cached forecast or an "unavailable" state instead of letting a request handler hang. ## Seeing the retry happen The retry is silent in normal logs. To confirm it while debugging, attach a debug stream with `http.set_debug_output($stderr)` before starting the session; Net::HTTP then prints its conversation, including a line reading "Conn close because of error" followed by "and retry" when it resends. The same stream prints every header, including `Authorization`, so it belongs on a developer machine only, never in production. A cheaper check in tests is to stub the server so it stalls once and assert how many requests arrived: two for a GET with default settings, one after `max_retries = 0`. ## Why teams add a client gem Net::HTTP is dependable and ships with Ruby, but it is low-level. Teams that make many outbound calls tend to wrap it or adopt a client gem for: | Need | Plain Net::HTTP | |---|---| | Connection reuse across threads | one object holds one socket; pooling is yours to build | | Retries with backoff and per-method policy | one silent retry, fixed method list | | Total request deadline | per-read, per-write and connect limits only | | Logging, metrics, tracing hooks | none built in | | Consistent error classes | mix of `Net::*`, `Errno::*` and `OpenSSL` errors | The judgement is whether those features are worth a dependency; for one or two calls a thin in-house wrapper around Net::HTTP is often enough.

  • Would the same stalled call made as a Net::HTTP::Post also take about 10 seconds?
    No. POST is not in Net::HTTP's idempotent method list, so after the first `Net::ReadTimeout` the exception is raised immediately. That is deliberate: resending a POST could create a second resource or charge twice.
  • Does Net::HTTP retry when the connection itself cannot be opened within open_timeout?
    No. The transport code rescues `Net::OpenTimeout` before the retryable errors and re-raises it, so a connect timeout surfaces after one `open_timeout` whatever `max_retries` says.
  • How can one call exceed read_timeout many times over without raising Net::ReadTimeout?
    `read_timeout` bounds each wait for the next chunk. If the server sends a little data before each wait expires, every read succeeds and the clock restarts, so the call can run for minutes. Only an overall deadline stops that.

saying these in an interview costs you the question

  • Net::HTTP never retries a request unless you write the retry
  • read_timeout caps the total time of the whole request
  • Net::HTTP retries POST requests once after a read timeout
  • Setting max_retries has no effect on GET requests
  • Net::OpenTimeout is retried like Net::ReadTimeout