EOFError: end of file reached
RCA: EOFError: end of file reached
Overview#
What Happened#
tesla (cupixworks-api) 가 외부/서드파티 HTTP 엔드포인트로 아웃바운드 요청을 보내는 도중 원격 peer 가 응답을 끝까지 내려주기 전에 TCP 소켓을 닫아 Ruby stdlib 의 Net::HTTP 가 EOFError: end of file reached 를 raise 했다. Datadog APM 은 이 span 을 built-in Net::HTTP 통합의 default service:net/http 로 태깅했을 뿐, 실제 애플리케이션은 tesla 이다. 2025-04-10 부터 2026-08-04 까지 약 16개월간 85건이 산발적으로 발생한 저빈도 이벤트다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | EOFError |
| exception.message | end of file reached |
| top_frame | Ruby stdlib net/http (Net::BufferedIO#rbuf_fill) — 애플리케이션 프레임 아님 |
| runtime | Ruby 3.3.7 |
| env | production (region 미상 — Error Tracking 전용, 매칭 로그 없음) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
tesla / cupixworks-api (outbound HTTP via Cupix::HttpClient) |
85 (16개월 누적) | 개별 아웃바운드 HTTP 호출 1건이 실패로 종료 — 재시도 없이 상위로 전파 |
service:net/http 는 실제 앱 서비스가 아니라 Datadog 의 Net::HTTP auto-instrumentation default span 이름이다. 실제 호출 주체는 tesla 이다.
Timeline#
- 2025-04-10 19:44 KST — 최초 발생 (first_seen, Representative Error 샘플)
- 2026-08-04 13:07 KST — 최근 발생 (last_seen). 16개월간 총 85건 산발 발생
- 2026-08-04 — RCA 수행. Datadog 로그(error/warn/info) 검색 결과 매칭 0건 — 이 이슈는 Error Tracking 에만 존재
Error Log#
end of file reached
Impact#
- Service:
net/http(실제 앱: tesla / cupixworks-api) - 발생 횟수: 85 (2025-04-10 ~ 2026-08-04, 약 16개월 누적)
- 최초 발생: 2025-04-10 19:44 KST
- 최근 발생: 2026-08-04 13:07 KST
Root Cause Summary#
EOFError: end of file reached 는 Ruby stdlib Net::HTTP 가 HTTP 응답을 읽는 도중 원격 서버가 TCP 소켓을 닫아 더 이상 읽을 바이트가 없을 때 Net::BufferedIO#rbuf_fill 에서 raise 하는 소켓 레벨 예외다. tesla 의 아웃바운드 HTTP 는 Cupix::HttpClient (내부적으로 RestClient → Ruby Net::HTTP 사용) 를 통하며, 이 때문에 Datadog 이 span 을 default service:net/http 로 태깅한다. 원인은 keep-alive connection 재사용 race (서버/로드밸런서가 idle timeout 으로 이미 닫은 커넥션을 클라이언트가 재사용), 프록시/LB idle timeout, 또는 원격 서버가 응답 도중 커넥션을 끊는 transient network 상황이다 — tesla 코드의 로직 결함이 아니다. 다만 Cupix::HttpClient 의 retry wrapper 가 rescue RestClient::Exception 로만 감싸고 있어 RestClient::Exception 의 후손이 아닌 stdlib EOFError 는 catch/재시도되지 않고 그대로 상위로 전파되는 resilience gap 이 존재한다.
Technical Analysis#
Code Path#
- APM span 태깅:
config/initializers/datadog.rb—net/http는 명시적service_nameoverride 가 없어 Datadog auto-instrument 의 defaultnet/httpspan 으로 잡힌다. 리스트업된 adapter(-excon,-faraday,-rest_client,-mysql2등)와 동일한 성격의 instrumentation service 이름이며 실제 앱 서비스가 아니다.
c.tracing.instrument :aws, service_name: global_service_name + '-aws'
c.tracing.instrument :elasticsearch, service_name: global_service_name + '-elasticsearch'
c.tracing.instrument :ethon, service_name: global_service_name + '-ethon'
c.tracing.instrument :excon, service_name: global_service_name + '-excon'
c.tracing.instrument :faraday, service_name: global_service_name + '-faraday'
c.tracing.instrument :redis, service_name: global_service_name + '-redis'
c.tracing.instrument :rest_client, service_name: global_service_name + '-rest_client'
-
Entry point (아웃바운드 HTTP):
lib/cupix/http_client.rb— tesla 는 rubocop ruleDisallowDirectHttpClient로 rawNet::HTTP/RestClient직접 사용을 금지하고 모든 아웃바운드 호출을Cupix::HttpClient로 강제한다.Cupix::HttpClient는RestClient를 호출하며,RestClient 2.1.0은 내부적으로 Ruby stdlibNet::HTTP를 사용한다 → 이것이service:net/httpspan 의 출처다. -
Failure point / resilience gap:
lib/cupix/http_client.rb:14-23
RETRIABLE_STATUS_CODES = [429, 502, 503, 504].freeze
MAX_RETRIES = 3
def self.get(url, headers = {}, retries: MAX_RETRIES)
attempt = 0
begin
RestClient.get(url, headers)
rescue RestClient::Exception => e
if RETRIABLE_STATUS_CODES.include?(e.http_code) && attempt < retries
attempt += 1
sleep((2**(attempt - 1)) + rand(0.0..0.5))
retry
end
raise
end
end
- 기대 동작: transient 한 네트워크/커넥션 장애 시 exponential backoff 로 재시도되어 사용자에게 노출되지 않는다.
- 실제 동작:
EOFError는 소켓 레벨에서 발생하며 HTTP status code 가 생성되기 전 예외다. 예외 계층상EOFError < IOError < StandardError로,RestClient::Exception < RuntimeError의 후손이 아니다(rest-client 2.1.0restclient/exceptions.rb:109—class Exception < RuntimeError). 따라서rescue RestClient::Exception절에 걸리지 않고, retry 없이 그대로 상위로 전파된다.
Log Evidence#
Datadog 로그(error/warn/info, 14일 retention)를 여러 쿼리로 검색했으나 매칭 로그가 0건이었다. 이 이슈는 Datadog Error Tracking 에만 존재하며 일반 로그 파이프라인에는 남지 않는다(과거 et: Excon/BadGateway 이슈들과 동일 패턴).
사용한 쿼리와 결과:
status:error "EOFError" (now-24h) → 0 logs
"end of file reached" (now-24h) → 0 logs
"EOFError" (now-7d) → 0 logs
service:cupixworks-api "EOFError" (now-14d) → 0 logs
service:cupixworks-worker "EOFError" (now-14d) → 0 logs
service:net/http (now-14d) → 0 logs
last_seen(2026-08-04 13:07 KST) 는 14일 retention window 안에 있음에도 매칭 로그가 없다 — Representative Error(end of file reached) 는 stdlib 가 생성하는 고정 메시지이므로 stale 여부와 무관하게 신뢰 가능하다(컬럼명 등 가변 파트가 없는 메시지). 즉 recent occurrence 도 동일 메시지다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 원격 peer 가 응답 완료 전 TCP 소켓을 닫아(keep-alive 재사용 race / LB·proxy idle timeout / 서버 조기 종료) stdlib Net::HTTP 가 EOFError 를 raise |
EOFError: end of file reached 는 Net::BufferedIO#rbuf_fill 의 전형적 소켓 조기 종료 메시지; service:net/http 는 Net::HTTP default span; 16개월 85건의 저빈도 산발 패턴은 transient network 특성 |
— | Confirmed |
| H2 | tesla 애플리케이션 코드의 로직 결함 (nil 참조, 잘못된 요청 조립 등) | — | top_frame 이 stdlib net/http 이고 앱 프레임 아님; 메시지가 요청 내용과 무관한 고정 소켓 예외 |
Rejected |
| H3 | Elasticsearch 호출이 원인 | elasticsearch-transport 가 faraday 의존 |
config/initializers/elasticsearch.rb:1 이 patron(libcurl 어댑터) 사용 → Net::HTTP 를 타지 않음, 별도 span. ES 트래픽은 net/http span 에 안 잡힘 |
Rejected |
| H4 | Cupix::HttpClient retry 가 이미 이 예외를 흡수해야 하는데 버그로 못함 |
Cupix::HttpClient 에 retry wrapper 존재 |
retry 는 rescue RestClient::Exception + status code [429,502,503,504] 조건. EOFError < IOError < StandardError 는 RestClient::Exception < RuntimeError 후손 아님 → 애초에 catch 대상이 아님. 버그가 아니라 미커버 케이스 |
Rejected (resilience gap 으로 재분류) |
Fix Recommendation#
즉시 조치 (Critical)#
없음. 이 에러는 원격 peer 의 커넥션 조기 종료라는 transient external network 상황이 원인이며 tesla 코드의 로직 결함이 아니다. 코드 변경 없이도 다음 요청은 정상 처리되며, 16개월 85건의 저빈도는 알람/장애 수준이 아니다.
단기 개선 (1주 이내)#
lib/cupix/http_client.rb— retry wrapper 의 rescue 범위를 넓혀 소켓 레벨 transient 예외(EOFError,Errno::ECONNRESET,Errno::ECONNREFUSED,Net::OpenTimeout,Net::ReadTimeout,RestClient::Exceptions::OpenTimeout,RestClient::Exceptions::ReadTimeout)를 idempotent 메서드(GET 등)에 한해 backoff 재시도하도록 보완. 단, POST/PUT/PATCH 는 재시도 시 중복 처리(비-idempotent) 위험이 있으므로 GET/DELETE 와 분리해 신중히 적용할 것.- 근거: 현재 wrapper 는
RestClient::Exception+ 특정 HTTP status 만 다뤄 소켓 레벨 예외를 전혀 커버하지 못한다. 이 gap 을 메우면 이런 transient EOFError 가 사용자/상위 로직에 노출되는 것을 대부분 흡수할 수 있다.
장기 개선 (재발 방지)#
- 아웃바운드 호출 대상별로 keep-alive/커넥션 재사용 정책과 idle timeout 을 점검 (특히 LB/프록시 뒤에 있는 대상). 클라이언트 idle timeout 을 서버/LB idle timeout 보다 짧게 두면 stale 커넥션 재사용 race 를 줄일 수 있다.
- Error Tracking 의
service:net/http이슈가 실제 앱(tesla)로 귀속되도록 APM 태깅/명명 정리를 검토해 원인 추적 비용을 낮춘다.
Monitoring#
- 아웃바운드 HTTP 에러 추이 (Net::HTTP span 기준):
sum:trace.net/http.request.errors{env:production}.as_rate()
- 전체 아웃바운드 요청량 대비 상대 빈도 확인:
sum:trace.net/http.request.hits{env:production}.as_rate()
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial (단기 개선을 채택할 경우 standard — idempotency 분기 때문)
- 사용자 영향: 개별 아웃바운드 호출 1건 실패에 국한, 16개월 85건의 저빈도 산발.
Noise Verdict#
noise — 원격 peer 의 커넥션 조기 종료로 stdlib Net::HTTP 가 raise 하는 transient network 예외이며 tesla 코드의 로직 결함이 아니므로(retry 범위 확대는 선택적 resilience 개선일 뿐 버그 수정 아님) noise 로 판정한다.