About

Faradayのopen_timeoutは未設定だとtimeoutの値になる

Net::HTTPのデフォルト60秒を信じた判断が根拠ごと崩れた。ブラックホールIPで3秒・1秒を測り、3区間を別々に決める基準まで

外部APIを呼ぶFaradayのコードに open_timeout がない場合、接続確立に何秒まで待つのか。読み終わると、自分のコードの実際の値を確かめ、接続・応答・送信の3区間にそれぞれ根拠のある値を付けられる。

結論から。conn.options.timeout だけを書くと open_timeout もその値になる。timeout = 300 なら接続だけで最大300秒待つ。Net::HTTPのデフォルト60秒は使われない。

最初の判断

仕事のプロジェクト(契約管理 SaaS)で、外部APIのタイムアウトを実測ベースで決め直す作業をした。リポジトリを開くと、APIクライアントがどれもこうなっていた。

Faraday.new(url: base_url) do |conn|
  conn.options.timeout = 300   # これだけ
  conn.adapter Faraday.default_adapter
end

「open_timeout がないからNet::HTTPのデフォルト60秒で動いているはず。接続に60秒かかる相手は正常じゃないから10秒に縮めよう」。ここまでが最初の判断だった。前提が間違っていた。

60秒は本当だから、かえって迷う

Net::HTTPのデフォルトは本当に60秒だ。

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

検索してもそう出てくる。問題はFaradayが間に一枚挟まっていること。Faradayはアダプタに渡す前に値をこう選ぶ。

# 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]        # なければ timeout へ
end

timeout はopen・read・writeの3区間すべてのフォールバックだ。1.10にも同じ行がある。Net::HTTPアダプタ(faraday-net_http 3.4.2)は値が来れば http.open_timeout = sec で上書きする。60秒は上書きされた後なので出番がない。

このブログが動いているリポジトリ(Ruby 3.2・Faraday 2.14.0)で実際に出してみた。

timeout = 300 だけ   open 300 / read 300 / write 300
何も設定しない       open nil / read nil / write nil
                     → Net::HTTPのデフォルト 60 / 60 / 60 が残る

何も書かなければ60秒で合っている。timeout を一つ書いた瞬間、3つともそれに従う。

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.

本当にそこまで待つのか

ルーティングされないプライベートIP(10.255.255.1)に繋ぐと、SYN(接続要求のパケット)に誰も応えない状況を作れる。

BLACKHOLE = 'http://10.255.255.1:81'

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

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

60秒ではなく3秒で切れる。フォールバックがそのまま効いている。

痛かったのは値より根拠

300か60かは実は二の次だった。open_timeout を10秒に縮めるPRには、こんな根拠を付けていた。

Net::ReadTimeout   リポジトリごとに数十〜100件余り
Net::OpenTimeout   0件

「記録されたタイムアウトは全部read側で、接続失敗は0件。接続は速いから10秒に縮めても安全だ」。

もっともらしいが成り立たない。これまでの実効値は300秒だったので、OpenTimeoutが0件なのは当たり前だ。この数字が言えるのは「300秒以内には繋がった」ことだけ。10秒以内に繋がる証拠はどこにもない。

10秒という結論は正しかった。接続区間はTCPとTLSのハンドシェイクだけで、正常なら数百msで終わる。それでも正しい結論に間違った根拠を付けておくと、次にこの値を触る人がその根拠を信じて判断する。「未設定なら60秒」というコメントが9か所に入ったままマージされるところだった。

open_timeoutが受け持つ区間

接続を張る区間と応答を待つ区間は性質が違う。readは「相手の処理にこれだけかかりうる」という業務側の数字だ。LLM呼び出しなら数十秒が普通。openは「ネットワークがこれだけで繋がる」というインフラ側の数字だ。一つの値でまとめるものではないのに、フォールバックのせいで黙ってまとめられていた。

HTTPSならTCP接続とTLSネゴシエーションがそれぞれ @open_timeout を使う。どこまで含まれるかはRubyのバージョンで違う。Ruby 3.1のnet/httpは Socket.tcp(..., connect_timeout:) なのでアドレス1つあたりの時間だ。DNSが複数IPを返すと合計が名目値を超えうる。Ruby 3.2のnet/httpとnet-http 0.9.1は TCPSocket.open を丸ごと Timeout.timeout(@open_timeout) で包むので、DNS解決も一緒に測る。「open_timeout 10秒なら最大10秒」と信じる前に、自分のRubyの net/http.rb を一度開いてみるといい。

実際に効くのは経路が切れたとき

open_timeoutが発動するには、相手がSYNを無視する必要がある。拒否(RST)でも遅いのでもなく、何も返ってこない状態だ。セキュリティグループの設定ミス、ロードバランサを作り直した後にDNSが古いIPを指している間、NAT Gatewayの障害のように、経路が切れたケースがほとんどだ。

逆にALBの後ろのバックエンドが落ちると、ALBがTCPを受けて502を返す。接続は成功しているのでread側の問題に見える。プロセスだけ落ちたサーバはOSがRSTを返すので即エラーになる。だからクラウド構成でopen_timeoutが実際に発動する場面は思ったより少ない。アプリ障害ではなく経路障害を捕まえる値で、経路障害はたいてい誰かがインフラを変えた日に起きる。ケース別の表は下の詳細にある。

縮めてもスループットは増えなかった

ここでもう一度間違えた。PRに「300秒ずつ掴んでいたワーカースレッドを10秒で離せば、一つのAPI障害が他のキューに広がるのを防げる」と書いた。その構成ではほとんど効かなかった。

OCRキュー   ─┐
抽出キュー  ─┼─→ 同じAPIホスト     経路が詰まれば3つとも詰まる
後続キュー  ─┘

OCR → 抽出 → 後続                  前段が失敗すると後ろのキューは元から空

キューは複数あったが、繋ぎに行く先は同じホストだった。ネットワークで詰まれば全部一緒に詰まる。しかも順に流れるパイプラインで、前段が失敗すると後ろのキューにはそもそも仕事が積まれない。スレッドを早く離しても拾う仕事がない。実際に得をするのはパイプラインの外のキュー(画面通知・Excel出力・メール)だけだった。

リトライも一緒に見る必要がある。再試行がなければ、早く失敗するのは復旧対象が早く増えるだけだ。再試行があって初めて「早く諦める」が「早くやり直す」になる。タイムアウトを縮める前にリトライを見る。

他のHTTPクライアントと比べると

                 timeoutだけ渡すと                     接続だけを指定
Faraday          open・read・writeすべて               options.open_timeout
Python requests  connect・readの両方                   timeout=(connect, read)
Go http.Client   リクエスト全体(接続・リダイレクト・本文)  net.Dialer{Timeout}
Net::HTTP        一つで渡すオプションなし(60 / 60 / 60)  open_timeout=

requestsの挙動はFaradayと同じだ。違うのは、ドキュメントがすぐ timeout=(3.05, 27) のようなタプルを見せるので、最初から分けて書くことになる点。Goは意味がまるで違う。Faradayの感覚で「timeout 30秒」をGoに持っていくと、大きいファイルのダウンロードが30秒で切れる。

同じ「timeout一つ」でも、ライブラリごとに意味が違う。ラッパーを使うときは、下のライブラリのデフォルトがそのまま見えていると信じない。この記事で一番言いたいのはこれだ。

自分のコードでやること

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

見つかった箇所ごとに3つの値を別々に書く。根拠も別々だ。openはネットワークの距離、readは実測のp99、writeはアップロードするファイルの大きさで決める。openを30秒にしたくなったら、タイムアウトではなくネットワークを疑う。区間ごとの数字・合計待ち時間の計算と、また抜けないように止めるspecの例は下の詳細にある。

外部APIをたくさん呼ぶRailsアプリでタイムアウトとリトライを一度整理したい、一緒に見てくれる人がほしいという場合は、negabaro に気軽に聞いてほしい。

open_timeoutが発動するケース・しないケース

状況open_timeout理由
セキュリティグループ・NACLの設定ミス発動するパケットが黙って捨てられ、SYNに応答がない
ロードバランサ作り直し後もDNSが古いIP発動するTTLの間、誰もいないIPに行く。外部SaaSのIP変更も同じ
NAT Gateway・ルーティングの事故発動する出ていくパケットがブラックホールに行く
インスタンスごと消えた発動する応答する主体がいない
相手のSYNキューが満杯発動する負荷で起きる。インフラを触らなくても出会う
プロセスだけ落ちた発動しないOSがRSTを返して即エラー
ALBは生きていてバックエンドだけ落ちた発動しないALBが接続を受けて502・503を返す → read側
応答が遅い・5xx発動しない接続は正常。read区間の問題

3つの値を決める根拠

区間何が決めるか根拠
openネットワークの距離同じVPC 1〜3秒・同一リージョンのALB 3〜5秒・インターネット越しのSaaS 5〜10秒
read相手の処理時間実測のp95・p99
write送るデータの大きさ最大アップロード ÷ 帯域

値を個別に決める前に、最悪の合計待ち時間を先に決める。リトライを入れると合計が倍々に増える。

合計待ち = (open + read) × (1 + リトライ回数)

また抜けないように — spec一枚

新しいAPIクライアントを足すときに同じミスを繰り返さないようにする。パスはプロジェクトに合わせて変える。

it 'timeoutを書いたクライアントには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

キーポイント

1

Faradayはopen・read・writeがなければoptions.timeoutを使う。timeoutだけ書くと接続もその値

2

Net::HTTPのデフォルト60秒は、timeoutを何も書かないときだけ残る

3

OpenTimeout 0件は「古い基準内で繋がった」だけで、縮めてよい根拠ではない

4

openはネットワークの距離、readは実測p99、writeはアップロードサイズで別々に決める

5

タイムアウトを縮める前に、リトライと、キューが別ホスト・独立した仕事を見ているかを確かめる

ユースケース

FaradayやNet::HTTPで外部API・LLMを呼ぶRailsアプリのタイムアウト点検 インフラ変更(セキュリティグループ・LB作り直し)の前に接続待ちが何秒か確かめるとき
← Stimulusをlazyにしても全コントローラを読む理由 正しいコミット2つが出会うとレビューでは止められない退行になる — Railsの自動紐付けが3ヶ月後に静かに死んだ理由 →