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#
- 08:28 UTC —
mask_upload_urlAPI로 마스크 업로드 URL 생성 (다수 pano에 대해 200 OK) - 08:29:57 UTC — 첫 번째
check_mask_uploading400 에러 발생 (pano 82155470) - 08:37:13 UTC — 마지막 에러 발생 (pano 82155432), 약 8분간 30+ pano에서 에러
- 08:41 UTC — 동일 pano 82155470에 대해 resize/tile 처리는 정상 완료 (200 OK)
Error Log#
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으로 전이하려 한다. 이때 두 가지 실패 모드가 발생한다:
- S3 eventual consistency 또는 업로드 실패: S3에 마스크 이미지가 아직/이미 존재하지 않아
Cupix::Errors::InvalidState("Mask does not uploaded")→ HTTP 400 - 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-141—changeMask를 병렬로 실행 (p-limit 10)
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
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:375—checkMaskUploadingSDK 메서드 내부
// 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
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
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 에러):
service:cupixworks-pano-postprocessor-instance status:error "mask pano"
Datadog 쿼리 (API-side 400 응답):
service:cupixworks-api check_mask_uploading 400
API-side 에러 — InvalidState (S3 마스크 미존재):
{
"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 충돌):
{
"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 정상 처리 타임라인:
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_statestate machine에서 재처리 시나리오를 명확히 지원하도록 리팩토링.mask_upload_url에서:uploading으로 전이하는 것이 재처리 시에도 항상 성공하는지 확인check_mask_uploading의 실패 경로에서 state 전이 실패를 별도 처리하여, 전이 실패가 원래 에러(마스크 미업로드)를 가리지 않도록 개선- 에이전트 측에서
checkMaskUploadingAPI의 400 응답 body에서 에러 코드를 파싱하여STAT10000(마스크 미업로드)과STAT10001(상태 전이 실패)를 구분하여 적절히 대응
Monitoring#
- 추가 메트릭:
check_mask_uploading400 응답 비율 모니터링
service:cupixworks-api "check_mask_uploading" 400
- 마스크 처리 성공/실패 비율 알림
service:cupixworks-pano-postprocessor-instance status:error "mask pano"
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard — state machine 전이 규칙 수정은 다른 마스크 워크플로우에 영향을 줄 수 있으므로 충분한 테스트 필요