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_retriesdefaults 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 = 0set. - 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.
| Method | Auto-retried? | Relevant here |
|---|---|---|
| GET | yes | the lookup/search/offers call this post is about |
| HEAD | yes | rarely used directly, same rule applies |
| PUT | yes | not used by the SellerMagnet API |
| DELETE | yes | not used by the SellerMagnet API |
| OPTIONS / TRACE | yes | not used by the SellerMagnet API |
| POST | no - never auto-retried | irrelevant here: every SellerMagnet endpoint is a GET |
| Exception | Auto-retried? | When it happens |
|---|---|---|
| Net::ReadTimeout | yes, auto-retried | the server accepted the connection but answered slowly |
| IOError / EOFError | yes, auto-retried | the connection dropped mid-response |
| Errno::ECONNRESET / ECONNABORTED / EPIPE | yes, auto-retried | the peer reset the connection |
| OpenSSL::SSL::SSLError | yes, auto-retried | a TLS-layer failure mid-request |
| Net::OpenTimeout | no - raised immediately | the 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"}
}
}
}

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.
| Client setting | Requests the server received | Extra billed send |
|---|---|---|
| Default client (max_retries = 1) | 2 | 1 silent extra send, same slow ASIN |
| Fixed client (max_retries = 0) | 1 | none - the timeout still raises, nothing resent |

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.