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_string → multi_match) |
| endpoint | GET /api/v1/reviews → Api::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#
- 2025-04-09 18:33 KST — 이슈 first_seen (Representative Error: geo
distance must be greater than zero, stale) - 2026-08-03 11:47 KST — 실제 최근 발생 (
multi_match/START_OBJECT after [query], env=dev, curl/8.7.1) - 2026-08-03 12:20 KST — 동일 패턴 재발 (동일 user_agent/env)
- 2026-08-04 — RCA 분석.
last_seen부근 로그 기준으로 root cause 재확정
Error Log#
Representative Error (cluster file 원문, stale):
[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):
{
"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#index 는 get_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
def index
review_query_option = Cupix::QueryOption::Review.new(get_query_option, params)
reviews = repository_instance.search(review_query_option)
get_query_option이params[:q]를 그대로query_string으로 전달:
_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호출:
self.set_search_string if @query_string.present?
- Failure point —
multi_match.query에@query_string을 타입 검증 없이 삽입:
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 쿼리 (재현용):
"x_content_parse_exception"
"[502] GET /api/v1/reviews" "x_content_parse_exception"
최근 발생 원문 (raw, 2026-08-03T03:20:41.797Z):
{
"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:elasticsearchactive 없음. 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 구분):
service:cupixworks-api "x_content_parse_exception" @http.url_details.path:/api/v1/reviews
- multi_match object-token 오류만 추적:
service:cupixworks-api "unknown token [START_OBJECT] after [query]"
- Reviews 검색 502 응답 추이:
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 을 반환한 클라이언트 입력 오류이며, 서버 코드 결함이 아니다.