Home / Blog / Ruby Net::HTTP Retries a Timed-Out GET by Default: Why That Doubles Your Bill

Ruby Net::HTTP Retries a Timed-Out GET by Default: Why That Doubles Your Bill

Net::HTTP.new(...).max_retries defaults to 1, so a GET that times out while the Amazon API is still answering gets silently re-sent once by Ruby itself, before your own retry code runs - and every send to a billed endpoint is billed. Here is a local test that proves it (2 requests for 1 call), the stdlib source line that causes it, and the one-line fix.

October 29, 2026
6 min read
SellerMagnet Team
Share & Bookmark
Terminal showing a single Ruby Net::HTTP call that results in two requests reaching the server

Ruby's Net::HTTP re-sends a timed-out GET once by default: Net::HTTP.new(...).max_retries starts at 1, not 0, so a slow-but-legitimate Amazon API response can trigger a second, fully billed request that your own code never asked for and never sees as an error. A local test below proves it: the same client call reaches a test server once with the fix, twice without it. The fix is one line, already in the Ruby quickstart's client, and this post is about the line most readers skip past.

Key Takeaways

  • Net::HTTP.new(...).max_retries defaults to 1 on every Ruby version from 2.5 to the current master - not 0.
  • That default auto-retries GET, HEAD, PUT, DELETE, OPTIONS and TRACE on Net::ReadTimeout, IOError, EOFError, a reset connection or an SSL error - before your own retry code runs.
  • Every retried send that reaches a billed API endpoint is a second billed request for the same logical call, with no error reported to your code.
  • A local test with a server that counts requests shows it directly: 2 requests for 1 call at the Ruby default, 1 request with max_retries = 0 set.
  • The fix is http.max_retries = 0, already set in the Ruby quickstart's client - this is the one line that matters if you wrote your own client instead.

Why does a slow Amazon API response bill a Ruby client twice?

Because Net::HTTP is built to retry on your behalf, and most Ruby code never turns that off. Net::HTTP#max_retries starts at 1 the moment you call Net::HTTP.new, and the client's own transport_request method rescues Net::ReadTimeout (among a few other transport errors) and, if the request method is one of GET HEAD PUT DELETE OPTIONS TRACE - all considered idempotent - it closes the socket and sends the exact same request again, up to max_retries times, before ever raising anything back to your code. A GET /amazon-product-lookup that takes a bit longer than your read_timeout is exactly this case: Ruby itself resends it once, silently, and the API bills both sends.

Which HTTP methods Net::HTTP will auto-retry (lib/net/http.rb, IDEMPOTENT_METHODS_).
MethodAuto-retried?Relevant here
GETyesthe lookup/search/offers call this post is about
HEADyesrarely used directly, same rule applies
PUTyesnot used by the SellerMagnet API
DELETEyesnot used by the SellerMagnet API
OPTIONS / TRACEyesnot used by the SellerMagnet API
POSTno - never auto-retriedirrelevant here: every SellerMagnet endpoint is a GET
Which errors trigger the auto-retry, straight from the stdlib source.
ExceptionAuto-retried?When it happens
Net::ReadTimeoutyes, auto-retriedthe server accepted the connection but answered slowly
IOError / EOFErroryes, auto-retriedthe connection dropped mid-response
Errno::ECONNRESET / ECONNABORTED / EPIPEyes, auto-retriedthe peer reset the connection
OpenSSL::SSL::SSLErroryes, auto-retrieda TLS-layer failure mid-request
Net::OpenTimeoutno - raised immediatelythe connection never opened, so nothing was sent to retry

How do I prove this myself, without spending API credits?

Run a tiny local server that counts the requests it receives and answers slower than the client's read_timeout, so a timeout is certain. Call it once with Ruby's default client, once with max_retries = 0, and read the server's own hit count - not the client's return value, which raises Net::ReadTimeout in both cases and tells you nothing about how many requests actually went out. The script below needs nothing beyond the Ruby standard library and never touches the real SellerMagnet API.

retry_proof.rb - counts real requests on a local server, no API key needed

# Proves Net::HTTP's own retry default against a local server you can run
# yourself - no SellerMagnet credits spent. Needs only the Ruby standard
# library: net/http, socket, json, uri.
require "json"
require "net/http"
require "socket"

# A tiny HTTP/1.1 server that counts every request it receives and can be
# told to answer slower than the client's read_timeout, so a timeout is
# guaranteed on the client side without relying on real network jitter.
class SlowServer
  attr_reader :hits

  def initialize(delay_seconds:)
    @delay = delay_seconds
    @hits = 0
    @mutex = Mutex.new
    @server = TCPServer.new("127.0.0.1", 0)
  end

  def port
    @server.addr[1]
  end

  def start
    Thread.new do
      Thread.current.report_on_exception = false
      loop do
        client = @server.accept
        Thread.new(client) do |c|
          Thread.current.report_on_exception = false
          while (line = c.gets) && line != "\r\n"; end
          @mutex.synchronize { @hits += 1 }
          sleep(@delay)
          body = JSON.generate("success" => true, "data" => {"asin" => "B0CL61F39H"})
          c.write "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}"
          c.close
        rescue IOError, Errno::EPIPE
          nil # the client already gave up - that is the scenario under test
        end
      end
    rescue IOError
      nil # @server.close from stop()
    end
  end

  def stop
    @server.close
  rescue IOError
    nil
  end
end

def one_request(port, max_retries:)
  http = Net::HTTP.new("127.0.0.1", port)
  http.open_timeout = 2
  http.read_timeout = 0.3 # shorter than the server's delay below: a timeout is certain
  http.max_retries = max_retries unless max_retries.nil?
  req = Net::HTTP::Get.new("/api/amazon-product-lookup?asin=B0CL61F39H")
  http.request(req)
  "no timeout (unexpected)"
rescue Net::ReadTimeout, Net::OpenTimeout => e
  e.class.name
end

unverified = Net::HTTP.new("x", 80).max_retries
puts "Net::HTTP.new(...).max_retries before any config = #{unverified}"

server = SlowServer.new(delay_seconds: 1.0)
server.start
raised = one_request(server.port, max_retries: nil) # nil = do not touch the default
sleep 0.2
puts "default client   max_retries=#{unverified}  -> client raised #{raised}, " \
     "server received #{server.hits} request(s) for ONE client.request call"
server.stop

server2 = SlowServer.new(delay_seconds: 1.0)
server2.start
raised2 = one_request(server2.port, max_retries: 0)
sleep 0.2
puts "fixed client     max_retries=0   -> client raised #{raised2}, " \
     "server received #{server2.hits} request(s) for ONE client.request call"
server2.stop

Output of 3 separate runs (byte-identical each time)

Net::HTTP.new(...).max_retries before any config = 1
default client   max_retries=1  -> client raised Net::ReadTimeout, server received 2 request(s) for ONE client.request call
fixed client     max_retries=0   -> client raised Net::ReadTimeout, server received 1 request(s) for ONE client.request call

That local server stands in for the real SellerMagnet API: its handler returns a body shaped exactly like a real /amazon-product-lookup response, trimmed to the fields that matter here. When the default client re-sends the GET, this is the body the server sends back - twice, for the same ASIN, on the same call.

Trimmed lookup response the test server (and the real endpoint) return per billed send

{
  "success": true,
  "data": {
    "productInfo": {
      "asin": "B0CL61F39H",
      "buyBoxInfo": {"price": 444.99, "currencyCode": "USD", "amazonSellerId": "ATVPDKIKX0DER"}
    }
  }
}
Blueprint sequence showing one Ruby Net::HTTP call resulting in two requests reaching the server when max_retries is left at its default
Net::HTTP resends the GET itself before your code ever sees the timeout.

How do I fix it?

Set http.max_retries = 0 on the client, once, right next to open_timeout and read_timeout. The Ruby quickstart's seller_magnet.rb already does this - @http.max_retries = 0 # Net::HTTP would silently re-send a timed-out GET; every send is billed - so a reader who built the client from that post is already covered. The line matters most for anyone who wrote a Ruby client by hand, or copied only part of the quickstart's class and dropped that one config line along the way. Zero does not mean 'never retry on a timeout': it only turns off Net::HTTP's own automatic resend, leaving your application-level retry policy - the one that correctly skips 404s and waits on Retry-After - as the only thing deciding what gets sent again.

Measured cost of the same slow response, with and without the fix.
Client settingRequests the server receivedExtra billed send
Default client (max_retries = 1)21 silent extra send, same slow ASIN
Fixed client (max_retries = 0)1none - the timeout still raises, nothing resent
Blueprint table comparing the measured request count for the default Ruby client against the fixed client
Same slow response, same read_timeout - the only difference is one config line.

This is not specific to the SellerMagnet API

Net::HTTP's default applies to any HTTP client you build on it, against any API. The reason it matters here is that every send to a billed endpoint costs a credit, so a default most Ruby code never touches turns a transport-layer detail into a line item. The same max_retries = 0 fix applies no matter which endpoint the client calls.

Frequently Asked Questions

Does Net::HTTP retry a timed-out GET by default in Ruby?

Yes. Net::HTTP.new(...).max_retries defaults to 1, and a GET (an idempotent method) that raises Net::ReadTimeout while the response is still coming is re-sent once automatically, before any retry code you wrote ever runs.

Does this double-bill every SellerMagnet API call that times out?

Only the ones your Ruby client leaves at the Net::HTTP default. Every send the API receives is billed per request, so one slow-but-legitimate response can turn into 2 billed attempts for 1 logical call, with no error telling you it happened twice.

Which errors does Net::HTTP auto-retry, and which does it not?

Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET/ECONNABORTED/EPIPE and OpenSSL::SSL::SSLError on a GET/HEAD/PUT/DELETE/OPTIONS/TRACE request. Net::OpenTimeout (the connection never opened) is raised immediately and is never auto-retried.

How do I stop Net::HTTP from silently re-sending a timed-out request?

Set http.max_retries = 0 on the client, as the Ruby quickstart's seller_magnet.rb already does. Zero means Net::HTTP never retries on its own; your own retry logic (for 429/500/502/503) stays in full control of what gets sent again.

Is this a SellerMagnet API bug?

No. It is Net::HTTP's own documented default (every Ruby RDoc page from 2.5 to the current master says so), and it applies to any HTTP API a Ruby client calls with the default client, not just this one.

Why does the proof use a local server instead of calling the real API?

So the double-send can be measured directly - the server literally counts requests - without spending real credits or depending on a real slow response happening on demand.

Bottom line: Net::HTTP.new(...).max_retries defaults to 1, so a GET that times out while waiting for a response gets resent once by Ruby itself, and both sends are billed if the endpoint charges per request. Set max_retries = 0, as the Ruby quickstart already does, and keep your own retry policy - covered in the rate limits and error handling guide - as the only thing that decides what gets sent twice. Full endpoint reference: API documentation.

Ready to Extract Amazon Data at Scale?

Start building with SellerMagnet API today. Real-time product data, competitive pricing, and review analytics at your fingertips.

150 free API credits • No credit card required • Cancel anytime