ES /docs

Elasticsearch::Transport::Transport::Errors::BadRequest: [400] {"error":{"root_cause":[{"type":"x_content_parse_exceptio

RCA: Elasticsearch::Transport::Transport::Errors::BadRequest [400] on GET /api/v1/reviews

Overview#

What Happened#

GET /api/v1/reviews (Api::V1::ReviewsController#index) 요청에서 Elasticsearch 가 [400] BadRequest 를 반환하고, 이것이 Rails 에서 502 로 응답되었다. 클러스터의 Representative Error 는 geo distance must be greater than zero (filter 파싱 실패) 였으나, 이는 STALE 하다. last_seen (2026-08-02) 부근의 실제 최근 로그(2026-08-03)는 다른 메시지 — [multi_match] unknown token [START_OBJECT] after [query] (failed to parse field [must]) — 를 보여준다. 두 변형 모두 클라이언트가 잘못된 형태의 검색 파라미터를 보내 Elasticsearch 쿼리 파싱이 실패한 사례이며, 관측된 최근 요청은 모두 env:dev, user_agent: curl/8.7.1 의 수동 호출이다.

Quick Facts#

Field Value
exception.class Elasticsearch::Transport::Transport::Errors::BadRequest
exception.message (recent) [400] {"error":{"root_cause":[{"type":"parsing_exception","reason":"[multi_match] unknown token [START_OBJECT] after [query]","line":1,"col":666}],"type":"x_content_parse_exception","reason":"[1:666] [bool] failed to parse field [must]"},"status":400}
exception.message (representative, stale) [400] ... "[bool] failed to parse field [filter]" ... "distance must be greater than zero"
top_frame lib/cupix/query_option/base.rb:157-165 (set_search_stringmulti_match)
endpoint GET /api/v1/reviewsApi::V1::ReviewsController#index
env dev (us-west-2)
deploy tesla develop (본 조사 시점 790e093bb)

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (Api::V1::ReviewsController#index) 14 (누적, 여러 변형 그룹핑) 잘못된 검색 파라미터를 보낸 개별 요청만 502 반환. 관측된 최근 요청은 dev 환경의 curl 수동 호출

Timeline#

  1. 2025-04-09 18:33 KST — 이슈 first_seen (Representative Error: geo distance must be greater than zero, stale)
  2. 2026-08-03 11:47 KST — 실제 최근 발생 (multi_match / START_OBJECT after [query], env=dev, curl/8.7.1)
  3. 2026-08-03 12:20 KST — 동일 패턴 재발 (동일 user_agent/env)
  4. 2026-08-04 — RCA 분석. last_seen 부근 로그 기준으로 root cause 재확정

Error Log#

Datadog Logs

Representative Error (cluster file 원문, stale):

text
[400] {"error":{"root_cause":[{"type":"x_content_parse_exception","reason":"[1:1206] [bool] failed to parse field [filter]"}],"type":"x_content_parse_exception","reason":"[1:1206] [bool] failed to parse field [filter]","caused_by":{"type":"illegal_argument_exception","reason":"distance must be greater than zero"}},"status":400}

실제 최근 발생 메시지 (Datadog, 2026-08-03):

json
{
  "error": {
    "message": "[400] {\"error\":{\"root_cause\":[{\"type\":\"parsing_exception\",\"reason\":\"[multi_match] unknown token [START_OBJECT] after [query]\",\"line\":1,\"col\":666}],\"type\":\"x_content_parse_exception\",\"reason\":\"[1:666] [bool] failed to parse field [must]\",\"caused_by\":{\"type\":\"parsing_exception\",\"reason\":\"[multi_match] unknown token [START_OBJECT] after [query]\",\"line\":1,\"col\":666}},\"status\":400}",
    "class": "Elasticsearch::Transport::Transport::Errors::BadRequest"
  }
}

Impact#

  • Service: cupixvista-elasticsearch (APM adapter service_name — 실제 앱은 cupixworks-api / tesla)
  • 발생 횟수: 14 (누적, Error Tracking 이 여러 파싱 오류 변형을 하나로 그룹핑)
  • 최초 발생: 2025-04-09 18:33 KST
  • 최근 발생: 2026-08-03 03:20 UTC = 2026-08-03 12:20 KST (cluster last_seen 2026-08-02 18:32 UTC = 2026-08-03 03:32 KST 부근)
  • 관측된 최근 요청은 전부 env:dev, user_agent: curl/8.7.1 수동 호출로, 정상 프런트엔드 트래픽은 아님

Root Cause Summary#

Api::V1::ReviewsController#indexget_query_option 을 통해 query_string: params[:q] (searchable_controller.rb:23) 을 그대로 QueryOption 에 전달한다. 클라이언트가 q 를 검색어 문자열이 아니라 ransack 스타일 정렬 해시 q[s]=published_at desc (즉 { "s": "published_at desc" }) 형태로 보내면, 이 Hash 가 @query_string 으로 세팅되고 @query_string.present? 검사(base.rb:94)를 통과해 set_search_string (base.rb:138-166)로 흘러간다. 거기서 multi_match: { query: @query_string, ... } (base.rb:158-159)의 query 필드에 스칼라 문자열 대신 JSON object 가 들어가면서 Elasticsearch 가 [multi_match] unknown token [START_OBJECT] after [query] 로 400 을 반환한다. 서버 코드 자체의 nil/로직 결함이 아니라, 검색 파라미터 타입을 검증하지 않아 잘못된 클라이언트 입력이 그대로 ES 쿼리로 전달되는 것이 원인이다. stale representative 의 distance must be greater than zero 역시 클라이언트가 geo-distance filter 에 0 거리를 넘긴 동종의 malformed-input 케이스다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/reviews_controller.rb:14-16
app/controllers/api/v1/reviews_controller.rb:14-16ruby
def index
  review_query_option = Cupix::QueryOption::Review.new(get_query_option, params)
  reviews = repository_instance.search(review_query_option)
  • get_query_optionparams[:q] 를 그대로 query_string 으로 전달:
app/controllers/concerns/searchable_controller.rb:19-27ruby
_query_option = QueryOption.new(
  per_page: params[:per_page],
  page: params[:page],
  sort: order_by(params[:sort], params[:order_by], params[:sort_order_by]),
  query_string: params[:q],
  visibility: params[:visibility],
  current_team: enable_current_team ? @current_team : nil,
  ids: params[:ids]
)
  • @query_string.present? 이면 set_search_string 호출:
lib/cupix/query_option/base.rb:94ruby
self.set_search_string if @query_string.present?
  • Failure point — multi_match.query@query_string 을 타입 검증 없이 삽입:
lib/cupix/query_option/base.rb:151-165ruby
if Float(@query_string, exception: false).nil?
  query_type = 'phrase_prefix'
else
  query_type = 'best_fields'
end

@query[:bool][:must] << {
  multi_match: {
    query: @query_string,
    fields: search_fields,
    type: query_type,
    lenient: true,
    analyzer: self.class.current_class&.analyzer_exist?(analyzer: 'searchquery_analyzer') ? 'searchquery_analyzer' : 'standard'
  }
}

기대 동작: q 는 스칼라 검색 문자열이어야 하며, multi_match.query 는 문자열/숫자를 받는다.

실제 동작: 클라이언트가 q[s]=published_at desc 로 보내면 params[:q]{ "s" => "published_at desc" } Hash 가 된다. 이 Hash 가 multi_match.query 자리에 들어가 직렬화되면 ES 는 query 값 위치에서 { (START_OBJECT) 를 만나 unknown token [START_OBJECT] after [query] 로 파싱 실패한다. Float(hash, exception: false)nil 을 반환하므로 phrase_prefix 경로로 진행되어 그대로 전송된다.

Log Evidence#

Datadog 쿼리 (재현용):

text
"x_content_parse_exception"
text
"[502] GET /api/v1/reviews" "x_content_parse_exception"

최근 발생 원문 (raw, 2026-08-03T03:20:41.797Z):

json
{
  "service": "cupixworks-api",
  "environment": "dev",
  "controller": "Api::V1::ReviewsController",
  "action": "index",
  "user_agent": "curl/8.7.1",
  "remote_ip": "3.172.65.16",
  "params": {
    "q": { "s": "published_at desc" },
    "per_page": "40",
    "fields": ["key", "name", "published_at", "facility"]
  },
  "http": { "status_code": 502, "method": "GET", "url_details": { "path": "/api/v1/reviews" } },
  "error": {
    "message": "[400] ... [multi_match] unknown token [START_OBJECT] after [query] ... [1:666] [bool] failed to parse field [must] ...",
    "class": "Elasticsearch::Transport::Transport::Errors::BadRequest"
  }
}
  • params.q{ "s": "published_at desc" } Hash 임이 로그에 직접 기록됨 — 정렬 파라미터를 q 로 잘못 전달한 것이 확인됨
  • 최근 발생(2026-08-03 02:47~03:20 UTC) 4건 모두 environment: dev, user_agent: curl/8.7.1, remote_ip: 3.172.65.10/16 로 동일한 수동 curl 호출 패턴
  • "failed to parse field [filter]" / "distance must be greater than zero" (representative 변형)는 최근 3일 로그에서 0건 (status:error "failed to parse field [filter]" → 0 results) → representative 는 stale
  • status-board: dep:elasticsearch active 없음. 2026-07-29 ES outage 는 별개 사건이며 본 클러스터와 무관 (400 파싱 오류는 outage 가 아님)

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 클라이언트가 q 를 검색어 문자열이 아닌 ransack 정렬 해시({s: ...})로 보내 multi_match.query 에 object 가 들어가 ES 400 로그의 params.q = {"s":"published_at desc"}, [multi_match] unknown token [START_OBJECT] after [query], code path base.rb:158-159 에 타입 검증 없음 Confirmed
H2 Representative 의 geo distance must be greater than zero 가 현재 근본 원인 representative error 원문에 존재 최근 3일 로그에 distance must be greater than zero / failed to parse field [filter] 0건. Error Tracking 이 오래된 first_seen 샘플을 pin 함 Rejected (stale)
H3 Elasticsearch outage / 인프라 장애 status-board 에 dep:elasticsearch scope 존재 active incident 없음, 에러가 400 파싱 오류(요청별)이며 503/timeout 아님, 특정 malformed 요청에만 발생 Rejected
H4 서버 코드의 nil/로직 결함으로 잘못된 쿼리 생성 set_search_string 에서 query 삽입 쿼리는 정상 스키마로 생성됨; ES 가 거부한 것은 클라이언트가 넣은 object 값. 정상 프런트엔드는 q 를 문자열로 전송 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 코드 결함이 아니므로 즉시 배포성 수정은 불필요. 최근 발생이 dev 환경 curl 수동 호출이라는 점을 기록.

단기 개선 (1주 이내)#

  • 입력 타입 검증: searchable_controller.rb:23 또는 base.rb:set_search_string 진입 지점에서 @query_string 이 Hash/Array 등 비-스칼라일 경우 방어 처리를 고려. 방향(택1):
    • query_string: params[:q]params[:q].is_a?(String) ? params[:q] : nil 로 좁혀 정렬용 q[s] 오용 시 검색어를 무시.
    • 또는 비-스칼라 q 를 감지하면 Cupix::Errors::Parameter (400) 로 명시적으로 반환해 502(ES 파싱 실패 pass-through) 대신 의미 있는 클라이언트 오류를 돌려줌.
  • 이 변경은 정상 프런트엔드 동작에 영향 없음(프런트엔드는 q 를 문자열로 전송). 다만 q[s] 를 정렬로 쓰는 클라이언트가 실제로 있는지 확인 후 진행.

장기 개선 (재발 방지)#

  • Reviews/검색 계열 컨트롤러의 q, sort, filter 파라미터에 대해 strong-parameter/스키마 수준의 타입 계약을 정의해 malformed 입력이 ES 쿼리로 그대로 흘러가지 않도록 표준화.
  • ES BadRequest(400) 는 502 가 아니라 클라이언트 오류(4xx)로 매핑하도록 응답 처리 재검토 — 현재 502 는 서버 오류로 오탐될 수 있음.

Monitoring#

  • 검색 파싱 실패 추이 (dev/prod 구분):
text
service:cupixworks-api "x_content_parse_exception" @http.url_details.path:/api/v1/reviews
  • multi_match object-token 오류만 추적:
text
service:cupixworks-api "unknown token [START_OBJECT] after [query]"
  • Reviews 검색 502 응답 추이:
text
service:cupixworks-api @controller:Api::V1::ReviewsController @action:index @http.status_code:502

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (수정 시 입력 타입 가드 1-2줄 수준)

Noise Verdict#

noise — 최근 발생은 모두 dev 환경 curl 수동 호출이 정렬용 q[s] 파라미터를 검색어로 잘못 넘겨 ES 가 400 을 반환한 클라이언트 입력 오류이며, 서버 코드 결함이 아니다.