StateMachines::InvalidTransition: Cannot transition mask_state via :missing from :uploaded (Reason(s): Mask state cannot
RCA: StateMachines::InvalidTransition: Cannot transition mask_state via :missing from :uploaded
Overview#
What Happened#
cupixworks-api (tesla) 의 PUT /api/v1/panos/:id/check_mask_uploading 엔드포인트에서 StateMachines::InvalidTransition 이 발생한다. 클라이언트가 아직 마스크 업로드가 완료되지 않은 pano 에 대해 상태 확인을 요청하면 MaskableRepository#check_mask_uploading 이 @model.missing_mask_state! 를 호출하는데, 해당 pano 의 mask_state 가 이미 uploaded 상태이면 missing 이벤트의 transition 규칙(any - %i[missing uploaded])이 uploaded 를 source 에서 제외하고 있어 예외가 발생한다. 이 예외는 ClientErrorController 에서 catch 되어 HTTP 400 (STAT10001) 으로 정상 변환되어 응답된다. 16개월간 201건, 배치성 폴링 요청에서 산발적으로 발생.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | StateMachines::InvalidTransition |
| exception.message | Cannot transition mask_state via :missing from :uploaded (Reason(s): Mask state cannot transition via "missing") |
| top_frame | app/repositories/concerns/maskable_repository.rb:44 |
| runtime | Ruby 3.3.7 / state_machines 0.6.0 |
| env | production (cupixworks-api) |
| http_status | 400 (STAT10001, Cupix::Errors::InvalidState) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (pano mask upload) | 201 (16개월) | 클라이언트가 400 응답을 받음. 서버 크래시/데이터 손상 없음 |
Timeline#
- 2025-01-22 21:01 KST — 최초 발생 (first_seen)
- 2026-07-28 17:12~17:24 KST — 배치성 발생 (동일 초에 다수의 distinct pano ID)
- 2026-08-03 15:10~15:12 KST — 최근 발생 (last_seen), pano 15364500 / 15364150
Error Log#
Cannot transition mask_state via :missing from :uploaded (Reason(s): Mask state cannot transition via "missing")
Impact#
- Service:
cupixworks-api - 발생 횟수: 201
- 최초 발생: 2025-01-22 21:01 KST
- 최근 발생: 2026-08-03 15:12 KST
Root Cause Summary#
이 예외는 pano mask_state state machine 의 의도된 가드가 작동한 결과다. check_mask_uploading 요청 시점에 마스크 객체가 아직 S3 에 업로드되지 않았으면 MaskableRepository#check_mask_uploading 의 else 분기(maskable_repository.rb:42-45)가 @model.missing_mask_state! (bang 메서드) 를 호출한다. 그런데 해당 pano 의 mask_state 가 이미 uploaded 인 경우, missing 이벤트의 transition 정의 transition any - %i[missing uploaded] => :missing (statable/pano.rb:200-202) 이 source state 에서 uploaded 를 제외하고 있어 유효한 전이가 없고 StateMachines::InvalidTransition 이 raise 된다. 이는 마스크가 한 번 업로드 완료(uploaded)된 뒤 클라이언트가 (배치/재폴링으로) check_mask_uploading 을 다시 호출하는 경합/중복 요청에서 발생한다. 핵심은 이 예외가 코드 결함으로 인한 서버 500 이 아니라, ClientErrorController 의 rescue_from StateMachines::InvalidTransition (client_error_controller.rb:19,37-39) 에 의해 HTTP 400 (STAT10001, "Cannot complete state transition") 로 정상 변환되어 클라이언트에게 반환된다는 점이다. 실제 Datadog 로그가 모두 [400] 인 것이 이를 증명한다.
Technical Analysis#
Code Path#
- Entry point:
PUT /api/v1/panos/:id/check_mask_uploading→MaskableController#check_mask_uploading - Repository:
MaskableRepository#check_mask_uploading - Failure point:
app/repositories/concerns/maskable_repository.rb:44(@model.missing_mask_state!) - Transition 정의:
app/models/concerns/statable/pano.rb:200-202 - Exception 처리:
app/controllers/concerns/client_error_controller.rb:19,37-39
begin
@model.uploaded_mask_state!
rescue StateMachines::InvalidTransition
if (retries += 1) <= 5 && @model.mask_state_uploading?
# ... uploaded 경로는 InvalidTransition 을 rescue + retry 로 흡수
sleep(retries)
retry
end
end
else
mask.missing
@model.missing_mask_state! # ← rescue 없음: uploaded 상태에서 호출되면 raise
raise Cupix::Errors::InvalidState.new(code: 'STAT10000', reason: 'Mask does not uploaded')
end
mask_state state machine 의 missing 이벤트는 uploaded 를 source 에서 제외한다. 따라서 pano 가 이미 uploaded 인 상태에서 missing_mask_state! 를 호출하면 유효 전이가 없어 예외가 발생한다.
event :uploading do
transition any => :uploading
end
event :missing do
transition any - %i[missing uploaded] => :missing
end
event :uploaded do
transition any - [:uploaded] => :uploaded
end
기대 동작: 마스크 업로드 확인 요청 시 마스크가 아직 없으면 missing 으로 전이. 실제 동작: pano 의 mask_state 가 이미 uploaded 로 완료되어 있으면 missing 전이가 거부되어 예외 발생. 하지만 이 예외는 컨트롤러 계층에서 400 으로 매핑된다.
rescue_from StateMachines::InvalidTransition, with: :invalid_transition_400_error
# ...
def invalid_transition_400_error(exception)
raise_error(400, exception, code: 'STAT10001', type: Cupix::Errors::InvalidState, reason: 'Cannot complete state transition', message: exception.message)
end
StateMachines::InvalidTransition < StateMachines::Error < StandardError (gem state_machines-0.6.0/lib/state_machines/error.rb:57, 3,57 라인). ServerErrorController 에는 이 클래스에 대한 catch-all 이 없고 (RuntimeError rescue 는 StandardError 하위이나 RuntimeError 는 아님), ClientErrorController 의 명시적 rescue_from 이 우선 적용되어 400 으로 처리된다.
Log Evidence#
Datadog 쿼리:
service:cupixworks-api "Cannot transition mask_state"
최근 14일 46건, 모두 [400] 상태. 대표 로그 (last_seen 부근):
{
"timestamp": "2026-08-03 15:12:54",
"status": "info",
"message": "[400] PUT /api/v1/panos/15364150/check_mask_uploading (Api::V1::PanosController#check_mask_uploading)",
"error": {
"message": "Cannot transition mask_state via :missing from :uploaded (Reason(s): Mask state cannot transition via \"missing\")",
"class": "StateMachines::InvalidTransition"
}
}
배치성 발생 패턴 (동일 초에 distinct pano ID 다수 → 클라이언트 일괄 폴링):
2026-07-28 08:24:09 panos/95502293
2026-07-28 08:24:09 panos/95490720
2026-07-28 08:24:09 panos/95501912
2026-07-28 08:24:09 panos/95494287
2026-07-28 08:24:09 panos/95501950
동일 pano 재요청 (재폴링): 95494287 이 08:12:12 와 08:12:14 에, 95501950 이 08:07:38 와 08:12:14 에 반복 등장. Representative Error 는 stale 하지 않으며 최근 로그와 완전히 일치한다. 최근 메시지가 대표 샘플과 동일한 고정 문자열임을 확인.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 클라이언트가 이미 uploaded 된 pano 에 대해 check_mask_uploading 을 (배치/재)폴링 → missing_mask_state! 가 missing 전이 거부로 예외 발생, 400 으로 처리 (noise) |
maskable_repository.rb:44 bang 호출; statable/pano.rb:200-202 transition 이 uploaded 제외; 로그 모두 [400]; 동일 초 distinct pano 다발 + 동일 pano 재요청 |
— | Confirmed |
| H2 | 이 예외가 unhandled 500 서버 에러로 표면화되는 코드 결함 (bug) | server_error_controller.rb 에 StateMachines::InvalidTransition 명시 rescue 없음 |
client_error_controller.rb:19 에 rescue_from StateMachines::InvalidTransition → 400; 실제 Datadog 로그 46/46 모두 [400] |
Rejected |
| H3 | uploaded_mask_state! 경로(정상 업로드)에서 발생하는 결함 |
— | 정상 경로는 maskable_repository.rb:29 에서 rescue StateMachines::InvalidTransition + retry 로 흡수; 메시지가 via :missing 이므로 missing 경로임이 확정 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
없음. 이 예외는 이미 ClientErrorController 에서 HTTP 400 (STAT10001) 으로 정상 처리되고 있으며, 서버 크래시나 데이터 손상이 없다. 코드 결함이 아니라 클라이언트의 중복/경합 폴링에 대한 방어적 상태 가드의 부산물이다.
단기 개선 (1주 이내)#
Error Tracking 노이즈를 줄이려면 MaskableRepository#check_mask_uploading 의 else 분기(maskable_repository.rb:42-45)에서 이미 mask_state_uploaded? 인 경우를 사전 판별하여 missing_mask_state! 를 호출하지 않도록 가드하는 방안을 검토할 수 있다. 정상 경로(uploaded_mask_state!, maskable_repository.rb:28-41)가 이미 rescue StateMachines::InvalidTransition 를 갖는 것과 대칭으로, missing 경로에도 동일하게 rescue StateMachines::InvalidTransition 를 추가하거나 respond_to?/상태 사전 체크를 두어 raise 자체를 억제하면 APM span error 가 사라져 Error Tracking 집계에서 빠진다. 단, 응답 자체는 이미 400 이므로 이는 알람 노이즈 감소 목적의 개선이며 기능적 버그 수정이 아니다.
장기 개선 (재발 방지)#
프런트엔드/에이전트의 check_mask_uploading 폴링 로직이 이미 uploaded 로 확인된 pano 에 대해 재요청하지 않도록 클라이언트 측 상태 캐싱을 검토. 서버 계약(400 STAT10001) 변경이 필요 없는 클라이언트 단독 개선이다.
Monitoring#
발생 추이 확인용 timeseries 쿼리:
service:cupixworks-api "Cannot transition mask_state"
400 응답 정상 처리 확인 (mask 상태 확인 400 비율):
service:cupixworks-api "check_mask_uploading" "[400]"
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial (수정 시), 또는 no-op (현 상태 유지)
Noise Verdict#
noise — 이 예외는 이미 업로드 완료된 pano 에 대한 클라이언트의 중복/경합 폴링에서 발생하는 의도된 상태 가드이며, 서버는 이를 HTTP 400 (STAT10001) 으로 정상 변환해 응답하므로 코드 결함이 아니다.