Faraday open_timeout 은 안 쓰면 timeout 값이 된다
Net::HTTP 기본 60초를 믿고 세운 판단이 근거째 틀렸다. 블랙홀 IP 로 3초 · 1초를 재 보고, 세 구간을 따로 정하는 기준까지
Faraday 로 외부 API 를 부르는 코드에 open_timeout 이 없다면, 그 코드는 접속을 맺는 데 몇 초까지 기다릴까. 다 읽으면 내 코드의 실제 값을 확인하고, 접속 · 응답 · 전송 세 구간에 각각 근거 있는 값을 줄 수 있다.
결론부터. 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 세 구간의 기본값이다. 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 하나를 쓰는 순간 셋 다 따라간다.
정말 그만큼 기다리나
라우팅이 안 되는 사설 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초」 라는 주석이 코드 아홉 군데에 들어간 채 머지될 뻔했다.
open_timeout 이 맡는 구간
접속을 맺는 구간과 응답을 기다리는 구간은 성격이 다르다. read 는 「상대가 처리에 이만큼 걸릴 수 있다」 는 업무 쪽 숫자다. LLM 호출이면 수십 초가 정상이다. open 은 「네트워크가 이만큼 안에 붙는다」 는 인프라 쪽 숫자다. 한 값으로 묶을 게 아닌데, 폴백 때문에 조용히 묶여 있었다.
HTTPS 면 TCP 접속과 TLS 협상이 각각 @open_timeout 을 쓴다. 어디까지 들어가는지는 Ruby 버전마다 다르다. Ruby 3.1 의 net/http 는 Socket.tcp(..., connect_timeout:) 라서 주소 하나당 시간이다. DNS 가 IP 를 여러 개 주면 합계가 명목값을 넘을 수 있다. Ruby 3.2 의 net/http 와 net-http 0.9.1 은 Timeout.timeout(@open_timeout) 으로 TCPSocket.open 을 통째로 감싸서 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 이 실제로 걸릴 일은 생각보다 적다. 앱 장애가 아니라 경로 장애를 잡는 값이고, 경로 장애는 대개 사람이 인프라를 바꾼 날 생긴다. 경우별 표는 아래 상세에 있다.
줄인다고 처리량이 느는 건 아니었다
여기서 한 번 더 틀렸다. 「워커 스레드를 300초씩 잡던 걸 10초로 줄이면, 한 API 장애가 다른 큐로 번지는 걸 막는다」 고 PR 에 적었다. 그 구성에서는 거의 효과가 없었다.
OCR 큐 ─┐
추출 큐 ─┼─→ 같은 API 호스트 경로가 막히면 셋 다 같이 막힌다
후속 큐 ─┘
OCR → 추출 → 후속 앞이 실패하면 뒤 큐는 원래 비어 있다
큐는 여러 개였지만 붙는 곳은 같은 호스트였다. 네트워크로 막히면 다 같이 막힌다. 게다가 차례로 이어지는 파이프라인이라, 앞 단계가 실패하면 뒤 큐에는 애초에 일이 안 쌓인다. 스레드를 빨리 놓아줘도 집어갈 일이 없다. 실제로 득을 보는 건 파이프라인 밖의 큐(화면 알림 · 엑셀 출력 · 메일)뿐이었다.
리트라이도 같이 봐야 한다. 재시도가 없으면 빨리 실패하는 건 복구할 건수가 빨리 느는 것일 뿐이다. 재시도가 있어야 「빨리 포기」 가 「빨리 다시 시도」 가 된다. 타임아웃을 줄이기 전에 리트라이부터 본다.
다른 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 는 아예 뜻이 다르다. 「timeout 30초」 를 Faraday 감각으로 Go 에 옮기면 큰 파일을 받다가 30초에서 끊긴다.
같은 「timeout 하나」 라도 라이브러리마다 뜻이 다르다. 래퍼 라이브러리를 쓸 때는 아래 라이브러리의 기본값이 그대로 보인다고 믿지 않는다. 이 글에서 제일 하고 싶은 말이 이거다.
내 코드에서 할 것
grep -rnE "options\.timeout|timeout: " app lib | grep -v open_timeout
나온 곳마다 세 값을 따로 쓴다. 근거도 따로다. open 은 네트워크 거리, read 는 실측 p99, write 는 올리는 파일 크기로 정한다. open 을 30초로 잡고 싶어지면 타임아웃이 아니라 네트워크를 의심한다. 구간별 숫자 · 총 대기 계산과 다시 빠지지 않게 막는 스펙 예제는 아래 상세에 있다.
외부 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 구간 문제 |
세 값을 정하는 근거
| 구간 | 무엇이 정하나 | 근거 |
|---|---|---|
| open | 네트워크 거리 | 같은 VPC 1~3초 · 같은 리전 ALB 3~5초 · 인터넷 너머 SaaS 5~10초 |
| read | 상대의 처리 시간 | 실측 p95 · p99 |
| write | 보낼 데이터 크기 | 가장 큰 업로드 ÷ 대역폭 |
값을 따로 정하기 전에 최악의 총 대기를 먼저 정한다. 리트라이를 넣으면 총합이 배로 뛴다.
총 대기 = (open + read) × (1 + 리트라이 횟수)
다시 빠지지 않게 — 스펙 한 장
새 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 는 업로드 크기로 따로 정한다
타임아웃을 줄이기 전에 리트라이와, 큐가 서로 다른 호스트 · 독립된 일을 보는지부터 확인한다