About

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 하나를 쓰는 순간 셋 다 따라간다.

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초」 라는 주석이 코드 아홉 군데에 들어간 채 머지될 뻔했다.

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

핵심 포인트

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 lazyLoad 로 바꿔도 컨트롤러를 다 받는 이유 각자 옳은 두 커밋이 만나면 리뷰로는 못 잡는 퇴행이 된다 — Rails 자동 연결이 3개월 뒤 조용히 죽은 이유 →