ES /docs

PanoPostprocessorService::run | mask pano id:82155470 | error {"stack":"HttpError: HTTP request fail

RCA: PanoPostprocessorService mask pano HTTP 400 Error

Overview#

What Happened#

2026-04-23 08:2908:37 UTC (KST 17:2917:37) 사이에 cupixworks-pano-postprocessor-instance 서비스에서 mask pano 처리 중 PUT /api/v1/panos/{id}/check_mask_uploading API 호출이 HTTP 400으로 실패했다. 약 30개 이상의 pano에서 동일한 에러가 발생했으며, 클러스터 기준 18건이 수집되었다. 에러는 두 가지 유형으로 나뉜다: (1) S3에 마스크 이미지가 존재하지 않아 InvalidState 발생, (2) 이전 마스크 처리로 mask_state가 이미 :uploaded인 상태에서 :missing으로 전이 불가한 InvalidTransition 발생.

Quick Facts#

Field Value
exception.class HttpError
exception.message HTTP request failed
top_frame panoApi.js:375
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
thehyundai 18+ 마스크(얼굴/몸체 블러) 처리 실패로 개인정보 마스킹이 적용되지 않은 pano가 생성될 수 있음

Timeline#

  1. 08:28 UTCmask_upload_url API로 마스크 업로드 URL 생성 (다수 pano에 대해 200 OK)
  2. 08:29:57 UTC — 첫 번째 check_mask_uploading 400 에러 발생 (pano 82155470)
  3. 08:37:13 UTC — 마지막 에러 발생 (pano 82155432), 약 8분간 30+ pano에서 에러
  4. 08:41 UTC — 동일 pano 82155470에 대해 resize/tile 처리는 정상 완료 (200 OK)

Error Log#

Datadog Logs

text
PanoPostprocessorService::run | mask pano id:82155470 | error {"stack":"HttpError: HTTP request failed
    at Request._callback (/tmp/agent/dist/node_modules/@tesla/typescript-node-sdk/api/panoApi.js:375:40)
    at self.callback (/tmp/agent/dist/node_modules/request/request.js:185:22)
    at Request.emit (node:events:524:28)
    at Request.emit (node:domain:489:12)
    at Request.<anonymous> (/tmp/agent/dist/node_modules/request/request.js:1154:10)
    at Request.emit (node:events:524:28)
    at Request.emit (node:domain:489:12)
    at IncomingMessage.<anonymous> (/tmp/agent/dist/node_modules/request/request.js:1076:12)
    at Object.onceWrapper (node:events:638:28)
    at IncomingMessage.emit (node:events:536:35)","message":"HTTP request failed","response":{"body":{},"statusCode":400},"body":{},"statusCode":400,"name":"HttpError"}

Impact#

  • Service: cupixworks-pano-postprocessor-instance
  • Team: thehyundai
  • 발생 횟수: 18 (클러스터 기준), 실제 30+ pano 영향
  • 최초 발생: 2026-04-23T08:29:57.490Z
  • 최근 발생: 2026-04-23T08:37:13.164Z

Root Cause Summary#

pano-postprocessor 에이전트의 changeMask 함수가 마스크 이미지를 S3에 업로드한 후 PUT /api/v1/panos/{id}/check_mask_uploading을 호출하여 업로드 완료를 확인한다. Rails API(MaskableRepository#check_mask_uploading)는 S3에서 마스크 객체 존재 여부를 확인하고, 존재하지 않으면 mask_state:missing으로 전이하려 한다. 이때 두 가지 실패 모드가 발생한다:

  1. S3 eventual consistency 또는 업로드 실패: S3에 마스크 이미지가 아직/이미 존재하지 않아 Cupix::Errors::InvalidState("Mask does not uploaded") → HTTP 400
  2. State machine 충돌: 이전 마스크 처리 사이클에서 mask_state가 이미 :uploaded인 pano에 대해 :missing 전이를 시도하면, state machine 정의(transition any - %i[missing uploaded] => :missing)에 의해 StateMachines::InvalidTransition → HTTP 400

두 번째 케이스는 re-processing(재처리) 시나리오에서 mask_state:uploading으로 리셋되지 않은 채 check_mask_uploading이 호출될 때 발생한다. mask_upload_url 메서드가 uploading_mask_state!를 호출하지만, 10개의 pano를 병렬로 처리할 때 race condition이 발생할 수 있다.

Technical Analysis#

Code Path#

  • Entry point: pano-postprocessor-service.ts:130-141changeMask를 병렬로 실행 (p-limit 10)
packages/cupix-pano-postprocessor/src/pano-postprocessor-service.ts:130-141typescript
const maskTasks = cpPanos.map((cpPano) => {
    return PARALLEL_TASK_LIMIT(async () => {
        try {
            await this.maskWork.changeMask(cpPano, maskTypeOption);
        } catch (error) {
            logger.error('PanoPostprocessorService::run | mask pano id:%d | error %s', cpPano.panoId, stringifyError(error));
            await this.panoPostprocessorManager.updatePanoState(cpPano.panoId!, TESLA.UpdatePanoRequest.StateEnum.Error);
            erroredPanoIds.add(cpPano.panoId!);
        }
    });
});
  • changeMask 실행 흐름: mask-work.ts:90-100
packages/cupix-pano-postprocessor/src/work/mask-work.ts:90-100typescript
async changeMask(cpPano: CPPano, maskType: 'face' | 'facebody') {
    if (!cpPano.panoId || !cpPano.maskImagePath || !fs.existsSync(cpPano.maskImagePath)) return;

    const mask = await this.createMask(cpPano.panoId, maskType);  // Step 1: POST mask_upload_url → 200 OK
    if (!mask) return;

    await this.awsS3Manager.uploadBySignedUrl(mask.uploadUrl, cpPano.maskImagePath);  // Step 2: S3 PUT
    await this.checkUploading(cpPano.panoId, maskType);  // Step 3: PUT check_mask_uploading → 400 ❌
    await this.updateMaskType(cpPano.panoId, mask.maskId);  // Step 4: never reached
}
  • Failure point: panoApi.js:375checkMaskUploading SDK 메서드 내부
node_modules/@tesla/typescript-node-sdk/api/panoApi.js:307-375javascript
// checkMaskUploading 메서드
// PUT /panos/{id}/check_mask_uploading?mask_type={maskType}
checkMaskUploading(id, fields, maskType, options = { headers: {} }) {
    // ...
    const localVarPath = this.basePath + '/panos/{id}/check_mask_uploading'
        .replace('{' + 'id' + '}', encodeURIComponent(String(id)));
    // ...
    if (response.statusCode && response.statusCode >= 200 && response.statusCode <= 299) {
        resolve({ response: response, body: body });
    } else {
        reject(new apis_1.HttpError(response, body, response.statusCode));  // line 375
    }
}
  • Rails API handler: maskable_repository.rb:4-49
app/repositories/concerns/maskable_repository.rb:4-49ruby
def check_mask_uploading(mask_type)
    _mask_type = mask_type.presence || 'custom'
    mask = @model.masks.find_by_mask_type(_mask_type)

    raise Cupix::Errors::NotFound.new(code: 'ARG10002', reason: "Mask not found. mask_type: #{mask_type}") if mask.nil?

    if mask.mask_uploaded?(mask_revision: mask.mask_upload_revision)
      mask.uploaded
      # ... state transitions for successful upload
    else
      mask.missing                    # mask model state → :missing
      @model.missing_mask_state!      # pano mask_state → :missing ← 여기서 실패
      raise Cupix::Errors::InvalidState.new(code: 'STAT10000', reason: 'Mask does not uploaded')
    end
end
  • State machine 제약: statable/pano.rb:200-201
app/models/concerns/statable/pano.rb:200-201ruby
event :missing do
    transition any - %i[missing uploaded] => :missing  # :uploaded에서 :missing으로 전이 불가
end

기대 동작: check_mask_uploading 호출 시 S3에 마스크 이미지가 존재하고, mask_state:uploading에서 :uploaded로 전이됨.

실제 동작: (1) S3에 마스크 이미지가 존재하지 않거나, (2) mask_state가 이미 :uploaded여서 :missing 전이가 실패하여 400 응답.

Log Evidence#

Datadog 쿼리 (pano-postprocessor 에러):

text
service:cupixworks-pano-postprocessor-instance status:error "mask pano"

Datadog 쿼리 (API-side 400 응답):

text
service:cupixworks-api check_mask_uploading 400

API-side 에러 — InvalidState (S3 마스크 미존재):

json
{
  "timestamp": "2026-04-23 17:37:15 KST",
  "status": "info",
  "message": "[400] PUT /api/v1/panos/82155432/check_mask_uploading (Api::V1::PanosController#check_mask_uploading)",
  "error": {
    "reason": "Mask does not uploaded",
    "code": "STAT10000",
    "message": "Mask does not uploaded",
    "class": "Cupix::Errors::InvalidState"
  }
}

API-side 에러 — InvalidTransition (state machine 충돌):

json
{
  "timestamp": "2026-04-23 17:29:58 KST",
  "status": "info",
  "message": "[400] PUT /api/v1/panos/82155470/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"
  }
}

pano 82155470 정상 처리 타임라인:

text
17:00:01 KST - [200] PUT check_uploading (리소스 업로드 확인)
17:28:07 KST - [200] POST mask_upload_url (마스크 업로드 URL 생성 #1)
17:28:12 KST - [200] POST mask_upload_url (마스크 업로드 URL 생성 #2)
17:28:20 KST - [200] POST mask_upload_url (마스크 업로드 URL 생성 #3)
17:29:58 KST - [200] PUT check_mask_uploading (성공 — face 타입)
17:29:58 KST - [400] PUT check_mask_uploading (실패 — facebody 타입, InvalidTransition)
17:30:42 KST - [200] PUT panos/82155470 (update — mask_id 설정)
17:41:03 KST - [200] POST tile_upload_credentials
17:41:06 KST - [200] PUT check_tile_uploading
17:41:15 KST - [200] PUT stitched

핵심 관찰: pano 82155470에 대해 mask_upload_url이 3번 호출되었고, check_mask_uploading이 같은 초(17:29:58)에 2번 호출되었다. 첫 번째는 200, 두 번째는 400. 이는 face와 facebody 두 가지 mask type이 순차적으로 처리되는 것이 아닌, 병렬 처리 또는 재시도 과정에서 state 충돌이 발생함을 시사한다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 S3 업로드 후 마스크 이미지가 아직 S3에 반영되지 않아 mask_uploaded?가 false를 반환 API 로그에서 Cupix::Errors::InvalidState: "Mask does not uploaded" 에러 다수 확인 (pano 82155432, 82155450, 82155454 등) S3는 강한 일관성(strong consistency) 모델을 사용하므로 PUT 직후 GET이 실패하는 것은 드묾 — 업로드 자체가 실패했을 가능성이 높음 Confirmed
H2 이전 마스크 처리로 mask_state:uploaded인 pano에서 재처리 시 :missing 전이 불가 API 로그에서 StateMachines::InvalidTransition: Cannot transition mask_state via :missing from :uploaded 에러 확인 (pano 82155470, 82155472, 82155473 등). State machine 코드(statable/pano.rb:200-201)에서 uploaded → missing 전이가 명시적으로 차단됨 Confirmed
H3 인증 토큰 만료로 S3 presigned URL이 유효하지 않아 업로드 실패 mask_upload_url 호출(17:28)과 check_mask_uploading 호출(17:29~17:37) 사이 시간이 짧아 토큰 만료 가능성 낮음. mask_upload_url 자체는 200 반환 presigned URL 만료 시간은 별도 설정($AWS[:s3][:put_presigned_url_expires_in])이며, 1분 이내 사용되므로 만료 가능성 극히 낮음 Rejected
H4 마스크 이미지 파일이 로컬에 생성되지 않아 changeMask가 S3에 빈/잘못된 파일을 업로드 changeMask에서 fs.existsSync(cpPano.maskImagePath) 검증이 있으나, 블러 스크립트의 Python warning 로그(RuntimeWarning: overflow encountered in exp)가 대량 발생 — 추론 과정에서 일부 마스크 이미지가 정상 생성되지 않았을 수 있음 maskPanos 스크립트는 exit code 0으로 완료된 것으로 보임 (별도 에러 로그 없음) Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • maskable_repository.rb:42-45: check_mask_uploading 메서드에서 @model.missing_mask_state! 호출 전에 현재 mask_state를 확인하고, 이미 :uploaded 상태이면 :missing 전이를 건너뛰거나 먼저 :uploading으로 리셋한 후 전이하도록 변경
  • statable/pano.rb:200-201: missing 이벤트의 전이 규칙에서 :uploaded 상태를 제외 목록에서 제거하여 :uploaded → :missing 전이를 허용하는 방안 검토 (단, 다른 코드에서 이 제약에 의존하는지 확인 필요)

단기 개선 (1주 이내)#

  • mask-work.ts:98: checkUploading 호출 실패 시 재시도 로직 추가. S3 업로드 후 존재 확인이 실패하면 일정 시간 대기 후 재시도하도록 구현
  • mask-work.ts:90-100: changeMask에서 S3 업로드(uploadBySignedUrl) 결과를 명시적으로 확인하고, 실패 시 checkUploading을 호출하지 않도록 변경
  • 마스크 블러 스크립트의 Python warning(overflow encountered in exp)이 마스크 이미지 품질/유효성에 영향을 미치는지 조사

장기 개선 (재발 방지)#

  • mask_state state machine에서 재처리 시나리오를 명확히 지원하도록 리팩토링. mask_upload_url에서 :uploading으로 전이하는 것이 재처리 시에도 항상 성공하는지 확인
  • check_mask_uploading의 실패 경로에서 state 전이 실패를 별도 처리하여, 전이 실패가 원래 에러(마스크 미업로드)를 가리지 않도록 개선
  • 에이전트 측에서 checkMaskUploading API의 400 응답 body에서 에러 코드를 파싱하여 STAT10000(마스크 미업로드)과 STAT10001(상태 전이 실패)를 구분하여 적절히 대응

Monitoring#

  • 추가 메트릭: check_mask_uploading 400 응답 비율 모니터링
text
service:cupixworks-api "check_mask_uploading" 400
  • 마스크 처리 성공/실패 비율 알림
text
service:cupixworks-pano-postprocessor-instance status:error "mask pano"

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard — state machine 전이 규칙 수정은 다른 마스크 워크플로우에 영향을 줄 수 있으므로 충분한 테스트 필요