ES /docs

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#

  1. 2025-01-22 21:01 KST — 최초 발생 (first_seen)
  2. 2026-07-28 17:12~17:24 KST — 배치성 발생 (동일 초에 다수의 distinct pano ID)
  3. 2026-08-03 15:10~15:12 KST — 최근 발생 (last_seen), pano 15364500 / 15364150

Error Log#

Datadog Logs

text
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_uploadingelse 분기(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 이 아니라, ClientErrorControllerrescue_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_uploadingMaskableController#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
app/repositories/concerns/maskable_repository.rb:27-46ruby
      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! 를 호출하면 유효 전이가 없어 예외가 발생한다.

app/models/concerns/statable/pano.rb:196-206ruby
        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 으로 매핑된다.

app/controllers/concerns/client_error_controller.rb:19,37-39ruby
    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 쿼리:

text
service:cupixworks-api "Cannot transition mask_state"

최근 14일 46건, 모두 [400] 상태. 대표 로그 (last_seen 부근):

json
{
  "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 다수 → 클라이언트 일괄 폴링):

text
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.rbStateMachines::InvalidTransition 명시 rescue 없음 client_error_controller.rb:19rescue_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_uploadingelse 분기(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 쿼리:

text
service:cupixworks-api "Cannot transition mask_state"

400 응답 정상 처리 확인 (mask 상태 확인 400 비율):

text
service:cupixworks-api "check_mask_uploading" "[400]"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (수정 시), 또는 no-op (현 상태 유지)

Noise Verdict#

noise — 이 예외는 이미 업로드 완료된 pano 에 대한 클라이언트의 중복/경합 폴링에서 발생하는 의도된 상태 가드이며, 서버는 이를 HTTP 400 (STAT10001) 으로 정상 변환해 응답하므로 코드 결함이 아니다.