ES /docs

Failed to get captures - Operation timed out after 10001 milliseconds with 0 bytes received

RCA: Failed to get captures - Operation timed out after 10001 milliseconds with 0 bytes received

Overview#

What Happened#

2026-06-27 04:17 KST에 cupixworks-api (us-west-2, production) 에서 ReviewRepository#captures 호출이 Elasticsearch 응답을 10초 동안 0바이트도 받지 못하고 timeout으로 실패했다. 2초 간격으로 2건만 관측되었으며, 같은 시간대(04:17 KST 부근)에 cupixworks-api service degraded 인시던트(2026-06-26-svc-cupixworks-api--unknown-4)에 다른 클러스터들과 함께 묶였다. 단일 사용자/리뷰 요청 흐름에서 발생한 isolated transient timeout으로 보이며, 04:28 KST 이후 동일 메서드는 정상 응답으로 복구되었다.

Quick Facts#

Field Value
exception.class (unknown — wrapped Faraday/curl timeout)
exception.message Operation timed out after 10001 milliseconds with 0 bytes received
top_frame app/repositories/concerns/accessible_entities_repository/review.rb:116
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (Review) 2 Review 페이지에서 capture 목록 조회 요청 일시적 실패 — 동일 review로 추정되는 2건의 호출이 10초 timeout 후 사용자에게 에러로 노출

Timeline#

  1. 2026-06-27 04:01 KST — 동일 서비스에서 Api::V1::FacilitiesController#index 가 52,435ms 소요 (latency 클러스터 37d67138-a043-4669-af16-3d0f6805fb46) — 같은 시간대 ES/DB 응답 지연 신호
  2. 2026-06-27 04:17:26 KSTReviewRepository#captures 1차 timeout (10001ms)
  3. 2026-06-27 04:17:28 KST — 2초 뒤 동일 메시지 재발 — 사용자가 재시도했거나 동일 페이지에서 재호출된 것으로 추정
  4. 2026-06-27 04:28:39 KSTaccessible_captures 정상 동작 확인 (info 로그 다수 관측, 자연 복구)

Error Log#

Datadog Logs

text
Failed to get captures - Operation timed out after 10001 milliseconds with 0 bytes received

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 2
  • 최초 발생: 2026-06-27 04:17 KST
  • 최근 발생: 2026-06-27 04:17 KST

Root Cause Summary#

ReviewRepository#captures 가 호출하는 accessible_captures 메서드는 Elasticsearch ::Capture.search(...).records 로 capture 목록을 가져온다. 이 HTTP 요청이 정확히 10,001ms 동안 응답을 한 바이트도 받지 못해 client-side timeout(libcurl Operation timed out ... 0 bytes received)으로 끊겼다. 메시지의 "0 bytes received" 와 10001ms 라는 깔끔한 수치는 Elasticsearch 노드/coordinator 가 응답을 시작도 하지 못한 상태에서 클라이언트가 read timeout 으로 끊은 패턴이다. 같은 시간대 FacilitiesController#index 가 52초 걸린 latency 이벤트와 묶이는 점, 그리고 11분 뒤 동일 코드 경로가 정상 동작한 점에서 root cause 는 cupixworks-api 코드 결함이 아니라 Elasticsearch (혹은 그 앞단 네트워크) 의 일시적 응답 지연/포화다. 추가로, captures/records rescue 블록은 Faraday::ConnectionFailed 에 한해서만 DB legacy path 로 fallback 하고, timeout 류 예외는 그대로 raise 되어 사용자에게 5xx/실패가 그대로 노출되는 구조라는 점이 이 트랜지언트 이벤트를 사용자 가시 에러로 만들었다.

Technical Analysis#

Code Path#

  • Entry point (rescue/로그 발생 지점): app/repositories/concerns/accessible_entities_repository/review.rb:110-118captures 메서드
  • Elasticsearch 호출 실제 위치: app/repositories/concerns/accessible_entities_repository/review.rb:155::Capture.search(...).records
  • Failure point (timeout 후 raise): app/repositories/concerns/accessible_entities_repository/review.rb:116Cupix::Logger.error("Failed to get captures - #{e.message}", ...) 직후 raise e
app/repositories/concerns/accessible_entities_repository/review.rb:110-118ruby
def captures(level_ids: nil, record_ids: nil, visibility: Cyclable.visibility[:UNTRASHED])
  accessible_captures(level_ids: level_ids, record_ids: record_ids, visibility: visibility)
rescue Faraday::ConnectionFailed
  Cupix::Logger.warn('Failed to get captures from ES. Try to get captures from legacy.', class: self.class.name, function: __method__)
  accessible_captures_legacy(level_ids: level_ids, record_ids: record_ids, visibility: visibility)
rescue => e
  Cupix::Logger.error("Failed to get captures - #{e.message}", class: self.class.name, function: __method__)
  raise e
end
app/repositories/concerns/accessible_entities_repository/review.rb:124-160ruby
def accessible_captures(level_ids: nil, record_ids: nil, visibility: Cyclable.visibility[:UNTRASHED])
  raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied') if @current_user.present? && !@model.readable_by?(@current_user)

  query_option = Cupix::QueryOption::Capture.new(visibility: visibility)

  if @model.has_attribute?(:facility_id)
    query_option.query[:bool][:must] << {
      term: {
        'facility.id': @model.facility_id
      }
    }
  end

  query_option.query[:bool][:must] += [
    { terms: { 'level.id': level_ids.presence || _level_ids(visibility: visibility) } },
    { terms: { 'record.id': record_ids.presence || _record_ids(visibility: visibility) } },
    { exists: { field: 'published_at' } }
  ]

  search_results = ::Capture.search(query_option.serializable_hash.merge(size: 10000)).records
  # ...
end

기대 동작: ::Capture.search(...).records 가 Elasticsearch 에 검색 요청을 보내 10000건까지의 capture 를 가져온다.

실제 동작: HTTP 클라이언트가 ES 응답 시작 자체를 받지 못한 채 10,001ms 가 경과해 read timeout 이 발생, 예외가 rescue 블록에 도달했다. 이때 예외는 Faraday::ConnectionFailed 가 아니라 timeout 류(예: Faraday::TimeoutError / Net::ReadTimeout / Elasticsearch::Transport::Transport::Errors::*) 였기 때문에 line 112 의 legacy fallback 분기를 타지 않고 line 115 의 rescue => e 로 들어가 그대로 raise 됐다. 결과적으로 records 메서드(line 10-18) 와 동일하게, Faraday::ConnectionFailed 이외의 ES 장애 시그널은 사용자 가시 실패가 되도록 코드 경로가 구성돼 있다.

추가 데이터 신호: 같은 capture 검색 코드는 size: 10000 으로 호출되어 ES 쪽 부하가 크지만, 이 클러스터의 발생량(2건) 과 11분 후 자연 복구를 고려할 때 이 호출 자체의 쿼리 형태가 root cause 라기보다는 ES 측 transient saturation 이 더 그럴듯하다.

Log Evidence#

Datadog 쿼리 (재현용):

text
service:cupixworks-api "Failed to get captures"
text
service:cupixworks-api @class:ReviewRepository @function:captures
text
service:cupixworks-api @class:ReviewRepository

핵심 로그 (시간 범위 2026-06-26T18:30:00Z ~ 2026-06-26T20:00:00Z):

json
{
  "timestamp": "2026-06-27 04:17:28",
  "status": "error",
  "message": "Failed to get captures - Operation timed out after 10001 milliseconds with 0 bytes received",
  "class": "ReviewRepository",
  "function": "captures"
}
json
{
  "timestamp": "2026-06-27 04:17:26",
  "status": "error",
  "message": "Failed to get captures - Operation timed out after 10001 milliseconds with 0 bytes received",
  "class": "ReviewRepository",
  "function": "captures"
}

자연 복구 증거 — 같은 코드 경로가 11분 뒤 정상 작동:

json
{
  "timestamp": "2026-06-27 04:28:39",
  "status": "info",
  "message": "accessible_entity_count: 1",
  "class": "ReviewRepository",
  "function": "accessible_captures"
}

같은 시간대 ES/DB 부하 신호 (latency 클러스터 37d67138-a043-4669-af16-3d0f6805fb46):

text
Api::V1::FacilitiesController#index (avg 52435ms, max 52435ms) at 2026-06-26T19:01:37.996Z

incident-board 그룹핑 — 2026-06-26-svc-cupixworks-api--unknown-4 (open, 19:01:37 ~ 19:22:47 UTC, 4 clusters: 37d67138, 9de1abf8, e2b8679d, cfe24462).

배제 증거: 같은 18:30 ~ 20:00 UTC 윈도에서 service:cupixworks-api status:error 전체를 조회해도 본 timeout 2건 외 다른 에러는 관측되지 않았다 — service-wide 장애가 아니라 이 코드 경로/요청에 한정된 transient 이벤트.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Elasticsearch 측 transient 지연/포화로 capture 검색 응답이 10초 안에 시작되지 못함 메시지 "0 bytes received" + 정확히 10001ms (read timeout) 패턴; 같은 시간대 FacilitiesController#index 가 52s 소요한 latency cluster 37d67138; 11분 뒤 동일 코드 경로 정상 동작 Confirmed
H2 코드 결함 — accessible_capturessize: 10000 큰 쿼리가 매번 ES 를 막음 line 155 가 size: 10000 으로 검색; large result set 2건만 발생 후 자연 복구; 11분 뒤 같은 review_id 들에 대해 빠르게 성공; service-wide 패턴 아님 Rejected
H3 외부 dependency 광역 outage (예: ES 클러스터 전면 장애) 동일 서비스 다른 cluster들과 svc 인시던트로 묶임 같은 윈도에 cupixworks-api 의 다른 error 가 없음; ES 통신 실패가 service-wide 로 번지지 않음 Rejected
H4 captures rescue 블록의 legacy fallback 누락으로 timeout 류가 사용자 노출 (사용자 가시화의 기여 원인) line 112 는 Faraday::ConnectionFailed 만 catch 해 legacy 로 fallback; timeout 류는 line 115 에서 그대로 raise Confirmed (contributing factor, not root cause)

Fix Recommendation#

즉시 조치 (Critical)#

  • 별도 코드 변경은 권장하지 않는다. 본 이벤트는 ES 측 transient 지연이 root cause 이고 자연 복구되었다. 대신 다음을 수행:
    • 2026-06-26-svc-cupixworks-api--unknown-4 인시던트에 묶인 다른 클러스터들(37d67138, 9de1abf8, cfe24462) 의 RCA 와 교차 확인해 같은 ES 윈도 패턴이라면 ES 측 (us-west-2) 클러스터 health/슬로우 쿼리/노드 saturation 을 운영팀에 공유.
  • 사용자 노출 완화 관점에서 app/repositories/concerns/accessible_entities_repository/review.rb:112 의 rescue 절을 timeout 류까지 확장하는 것을 검토 — 현 상태는 Faraday::ConnectionFailed 에만 legacy fallback 이 동작하기 때문에 ES 가 느려졌을 때 (Faraday::TimeoutError / Net::ReadTimeout 등) 사용자에게 그대로 실패가 노출된다. 같은 패턴이 records/levels/annotation_layers/bims/floorplans 메서드에 반복적으로 존재 (각각 line 12, 67, 165, 208, 251) — 일괄적으로 timeout 클래스를 fallback 대상에 포함할지 검토.

단기 개선 (1주 이내)#

  • ::Capture.search(...).merge(size: 10000) 처럼 검색에 size: 10000 을 그대로 박아두는 패턴(line 57, 102, 155, 190, 241, 290)을 검토 — 큰 result set 호출은 ES 부하 / 응답 지연이 발생할 때 timeout 확률을 키운다. review 의 실제 entity 수 분포를 메트릭으로 확인하고 적정 size 또는 scroll/pagination 전환 여부 판단.
  • 클라이언트 timeout 설정 검토 — 10초가 review 페이지 동작 SLA 에 적합한지 product 팀과 정합성 확인. timeout 시 사용자에게 보이는 에러 메시지/재시도 UX 도 함께 점검.

장기 개선 (재발 방지)#

  • cupixworks-api 의 ES 의존 코드 경로 전반에 대해 (a) 클라이언트 timeout / (b) circuit breaker (예: 짧은 시간 내 N회 timeout 이면 short-fail) / (c) ES 검색을 wrapping 하는 공통 유틸로 fallback/관측 가능성 정리.
  • ES 클러스터 capacity/saturation 모니터링 강화 — search latency P95/P99, coordinator queue, JVM heap pressure, thread pool reject 메트릭을 알람 임계와 함께 정비.

Monitoring#

ES capture 검색 timeout 빈도:

text
sum:trace.rack.request.errors{service:cupixworks-api,resource_name:Api::V1::ReviewsController#captures}.as_count()

ReviewRepository#captures 에러 로그 카운트 (Datadog log facet 기반 timeseries):

text
logs("service:cupixworks-api status:error @class:ReviewRepository @function:captures").index("*").rollup("count").by("@function")

Api::V1::FacilitiesController#index 등 ES 의존 endpoint 의 P95 latency:

text
p95:trace.rack.request{service:cupixworks-api,resource_name:Api::V1::FacilitiesController#index}

위 쿼리는 writing-datadog-monitoring-queries skill 규칙에 따라 timeseries widget 에 들어갈 수 있도록 monitor-only 문법(| stats, count by(...)) 을 사용하지 않았다.

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (코드 변경 없음 / 또는 rescue 절 timeout 클래스 확장만)