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つともそれに従う。
本当にそこまで待つのか
ルーティングされないプライベート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
キーポイント
Faradayはopen・read・writeがなければoptions.timeoutを使う。timeoutだけ書くと接続もその値
Net::HTTPのデフォルト60秒は、timeoutを何も書かないときだけ残る
OpenTimeout 0件は「古い基準内で繋がった」だけで、縮めてよい根拠ではない
openはネットワークの距離、readは実測p99、writeはアップロードサイズで別々に決める
タイムアウトを縮める前に、リトライと、キューが別ホスト・独立した仕事を見ているかを確かめる