About

Faraday open_timeout: Leave It Unset and It Takes Your timeout

I trusted Net::HTTP's 60-second default and my reasoning broke with it. A black-hole IP shows 3 s vs 1 s, then how to set each phase

If your Faraday client for an external API never sets open_timeout, how long does it wait to connect? By the end of this you can check the real value in your own code and give the connect, read and write phases each a number you can defend.

Short version: set only conn.options.timeout and open_timeout takes that value too. With timeout = 300, just opening the connection can take up to 300 seconds. Net::HTTP's 60-second default never comes into play.

The call I made first

On a work project (a contract management SaaS) I was asked to re-tune external API timeouts from real measurements. Every API client in the repo looked like this.

Faraday.new(url: base_url) do |conn|
  conn.options.timeout = 300   # this is all there is
  conn.adapter Faraday.default_adapter
end

"No open_timeout, so it runs on Net::HTTP's 60-second default. Anything that takes 60 seconds to connect is broken anyway, so let's cut it to 10." That was my first take. The premise was wrong.

The 60 seconds is true, which is what makes it confusing

Net::HTTP really does default to 60 seconds.

require 'net/http'
Net::HTTP.new('example.com').open_timeout  # => 60

Search for it and that's what you'll find. The catch is that Faraday sits in between. Before handing anything to the adapter, it picks the value like this.

# faraday 2.14.0  lib/faraday/adapter.rb
def request_timeout(type, options)
  key = TIMEOUT_KEYS.fetch(type) { ... }   # :open → :open_timeout
  options[key] || options[:timeout]        # fall back to timeout
end

timeout is the fallback for all three phases: open, read and write. Faraday 1.10 has the same line. The Net::HTTP adapter (faraday-net_http 3.4.2) then sets http.open_timeout = sec whenever a value arrives. The 60-second default is overwritten before it matters.

I printed the values in the repo this blog runs on (Ruby 3.2, Faraday 2.14.0).

only timeout = 300   open 300 / read 300 / write 300
nothing set          open nil / read nil / write nil
                     → Net::HTTP keeps its 60 / 60 / 60

Set nothing and you do get 60 seconds. Set timeout once and all three follow it.

conn.options.timeout = 300 (nothing else set)What I assumedopen 60sNet::HTTP defaultread 300swrite 60sNet::HTTP defaultoptions[key]|| :timeoutWhat Faraday hands to Net::HTTPopen 300sjust to connectread 300swrite 300sTwo kinds of waiting, one numberconnect: TCP + TLShealthy: under 1swaiting for the responsean LLM call can take minutesopen_timeoutread_timeoutSet them apart: open by network distance, read by measured p99, write by upload size.

Does it really wait that long?

Connecting to a non-routable private IP (10.255.255.1) gives you a SYN (the connection request packet) that nobody answers.

BLACKHOLE = 'http://10.255.255.1:81'

Faraday.new(url: BLACKHOLE) { |f| f.options.timeout = 3 }.get('/')
# Faraday::ConnectionFailed after 3.0 s

Faraday.new(url: BLACKHOLE) { |f|
  f.options.timeout = 3
  f.options.open_timeout = 1
}.get('/')
# Faraday::ConnectionFailed after 1.0 s

It gives up at 3 seconds, not 60. The fallback is doing exactly what the code says.

What hurt was the reasoning, not the number

Whether it was 300 or 60 turned out to be secondary. The PR that cut open_timeout to 10 seconds came with this evidence.

Net::ReadTimeout   tens to 100-odd per repo
Net::OpenTimeout   0

"Every recorded timeout is on the read side and connect failures are zero. Connecting is fast, so 10 seconds is safe."

Sounds fine, but it doesn't hold. The real limit had been 300 seconds all along, so zero OpenTimeouts is exactly what you'd expect. The data only says "it connected within 300 seconds." Nothing in it shows a connection finishing within 10.

The 10-second conclusion was right. Connecting is just the TCP and TLS handshakes, and a healthy one finishes in a few hundred milliseconds. But pin the wrong reason to the right answer and the next person who touches the value will trust that reason. Comments saying "unset means 60 seconds" in nine places nearly got merged.

What open_timeout covers

Opening a connection and waiting for a response are different kinds of time. The read timeout is a business number: "this call can legitimately take this long." For an LLM call, tens of seconds is normal. The open timeout is an infrastructure number: "the network connects within this much." They shouldn't share one value, and the fallback had quietly made them share one.

Over HTTPS, the TCP connect and the TLS handshake each use @open_timeout. What else falls inside depends on your Ruby. Ruby 3.1's net/http calls Socket.tcp(..., connect_timeout:), which is per address, so when DNS returns several IPs the total can go past the nominal value. Ruby 3.2's net/http and net-http 0.9.1 wrap the whole TCPSocket.open in Timeout.timeout(@open_timeout), so the DNS lookup is counted too. Before assuming "open_timeout 10 means at most 10 seconds," open your Ruby's net/http.rb once.

When it actually fires: the path is broken

For open_timeout to fire, the other side has to ignore your SYN. Not refuse it (RST), not answer slowly, just stay silent. Most of the time that's a broken path: a security group mistake, DNS still pointing at the old IP after a load balancer was recreated, a NAT Gateway incident.

When the backend behind an ALB dies, the ALB still accepts TCP and returns a 502. The connection succeeded, so it shows up as a read problem. A server whose process died but whose OS is up sends an RST and you get an error right away. So in a cloud setup, open_timeout fires less often than you'd think. It catches path failures, not app failures, and path failures usually happen on the day someone changes the infrastructure. There's a table of cases in the details below.

Shorter timeouts didn't buy throughput either

I got one more thing wrong. The PR said "cutting a 300-second hold on worker threads to 10 seconds keeps one API's outage from spreading to other queues." In that setup it barely helped.

OCR queue        ─┐
extraction queue ─┼─→ same API host     path breaks, all three break
follow-up queue  ─┘

OCR → extraction → follow-up           early step fails, later queues stay empty

There were several queues, but they all called the same host. If the network path breaks, they all break together. On top of that it was a pipeline, so when an early step fails, nothing ever lands in the later queues. Freeing threads sooner doesn't help when there's no work to pick up. Only the queues outside the pipeline (UI notifications, Excel exports, mail) actually gained.

Retries belong in the same decision. Without them, failing fast just means piling up cases to recover faster. With them, "give up sooner" becomes "try again sooner." Look at retries before you shorten timeouts.

How other HTTP clients compare

                 only timeout set                        connect only
Faraday          open, read and write all take it        options.open_timeout
Python requests  connect and read both take it           timeout=(connect, read)
Go http.Client   the whole request (connect, redirects,  net.Dialer{Timeout}
                 reading the body)
Net::HTTP        no single option (60 / 60 / 60)         open_timeout=

requests behaves like Faraday. The difference is that its docs show a tuple like timeout=(3.05, 27) right away, so people split the values from the start. Go means something else entirely. Carry a Faraday-style "30-second timeout" over to Go and a large download gets cut off at 30 seconds.

"One timeout" means different things in different libraries. When you use a wrapper, don't assume the underlying library's defaults show through. That's the main point of this post.

What to do in your code

grep -rnE "options\.timeout|timeout: " app lib | grep -v open_timeout

Everywhere it hits, set the three values separately, each with its own reason. Open by network distance, read by measured p99, write by the largest upload. If you catch yourself wanting a 30-second open timeout, suspect the network, not the timeout. Numbers per phase, the total-wait math and a spec that keeps this from slipping back are in the details below.

If you'd like to sort out timeouts and retries in a Rails app that calls a lot of external APIs, or want someone to look at it with you, feel free to ask negabaro.

When open_timeout fires, and when it does not

Situationopen_timeoutWhy
Security group or NACL mistakefiresPackets are silently dropped, so the SYN gets no answer
DNS still on the old IP after a load balancer rebuildfiresFor the TTL, clients go to an IP with nothing behind it. Same when an external SaaS changes IPs
NAT Gateway or routing incidentfiresOutgoing packets go into a black hole
The instance is gone entirelyfiresNothing is there to answer
The server's SYN queue is fullfiresCaused by load. You can hit it without touching infra
Only the process dieddoes notThe OS sends an RST, so you get an error right away
ALB is up, backend is downdoes notThe ALB accepts the connection and returns 502/503, which is the read side
Slow response or 5xxdoes notThe connection is fine. It is a read-phase problem

What each of the three values is based on

PhaseDecided byEvidence
openNetwork distanceSame VPC 1-3 s, same-region ALB 3-5 s, SaaS across the internet 5-10 s
readHow long the other side worksMeasured p95 / p99
writeHow much data you sendLargest upload / bandwidth

Before setting values one by one, decide the worst-case total wait. Adding retries multiplies it.

total wait = (open + read) × (1 + retries)

Keep it from slipping back: one spec

Stops the same mistake when someone adds a new API client. Change the path to fit your project.

it 'every client that sets timeout also sets open_timeout' do
  missing = Dir[Rails.root.join('app/clients/**/*.rb')].select do |path|
    code = File.readlines(path).reject { |l| l.strip.start_with?('#') }.join
    code.include?('options.timeout') && !code.include?('open_timeout')
  end

  expect(missing).to be_empty
end

Key Points

1

Faraday falls back to options.timeout for open, read and write. Set only timeout and connecting uses it too

2

Net::HTTP's 60-second default survives only when no timeout is set at all

3

Zero OpenTimeouts only means "it connected within the old limit," not that a shorter one is safe

4

Set open by network distance, read by measured p99, write by upload size

5

Before shortening timeouts, check retries and whether your queues hit different hosts and fill independently

Use Cases

Auditing timeouts in a Rails app that calls external APIs or LLMs through Faraday or Net::HTTP Checking how long connects can hang before an infra change like security groups or a load balancer rebuild
← Why Stimulus lazyLoad Still Downloads Every Controller Two Correct Commits, One Silent Regression: Why a Rails Auto-Link Feature Died Three Months Later →