ES /docs

TransferManager::uploadFile | response path: /tmp/workspace/684428/videos/644427/original/644427_000

RCA: TransferManager::uploadFile | 502 Bad Gateway

Overview#

What Happened#

2026-04-21 18:19:34 UTC에 cupixworks-capture-postprocessor-agent 서비스에서 capture 684428의 video 644427 pano 이미지(644427_00033.jpg)를 업로드하는 중 HTTP 502 Bad Gateway 응답을 수신했다. 동일 시간대에 eu-central-1 리전에서도 다수의 upload 실패(500 Internal Server Error, ETIMEDOUT)가 발생하여, 업스트림 스토리지/게이트웨이 계층의 일시적 장애가 의심된다.

Quick Facts#

Field Value
exception.message TransferManager::uploadFile | response path: /tmp/workspace/684428/videos/644427/original/644427_00033.jpg, code: 502, message: Bad Gateway
top_frame transfer.manager.ts:99
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
nhc-sa (capture postprocessor) 1 capture 684428 pano 업로드 실패. 재시도 메커니즘(최대 5회)이 존재하나, 최종 성공 여부는 로그에서 확인 불가
innovo (capture postprocessor, eu-central-1) 12 동일 시간대 captures 33890/33892/33893에서 500, ETIMEDOUT 업로드 에러 다수 발생

Timeline#

  1. 18:16:58 UTC — host ip-10-1-37-70에서 workspace 684428 cleanup 실행 (info 로그)
  2. 18:17:19 UTC — host ip-10-1-109-237에서 workspace 684428 cleanup 실행 (info 로그)
  3. 18:19:34.996 UTC — host ip-10-1-171-116에서 644427_00033.jpg 업로드 시 502 Bad Gateway 수신 (error 로그)
  4. 18:19:34.997 UTCCupixAuth::handleError에서 502 PUT 응답 경고 (warn 로그)
  5. 18:21:11 UTC — host ip-10-1-171-116에서 workspace 684428 cleanup 실행 (info 로그)

Error Log#

Datadog Logs

text
TransferManager::uploadFile | response path: /tmp/workspace/684428/videos/644427/original/644427_00033.jpg, code: 502, message: Bad Gateway

Impact#

  • Service: cupixworks-capture-postprocessor-agent
  • Team: nhc-sa
  • 발생 횟수: 1
  • 최초 발생: 2026-04-21T18:19:34.996Z
  • 최근 발생: 2026-04-21T18:19:34.996Z

Root Cause Summary#

업스트림 스토리지 서비스(Tesla API 또는 로드밸런서/리버스 프록시 계층)가 PUT 업로드 요청에 대해 일시적으로 502 Bad Gateway를 반환했다. 동일 시간대에 eu-central-1 리전에서도 다수의 500 Internal Server Error 및 ETIMEDOUT 에러가 발생한 것으로 보아, 업스트림 서비스의 일시적 장애 또는 과부하가 근본 원인으로 판단된다. TransferManageruploadFile 메서드는 presigned URL로 HTTP PUT 요청을 전송하며, 502 응답 수신 시 CupixAuth::handleError를 통해 에러를 로깅하고 reject한다. 이후 retryTask에서 재시도가 가능하나(502는 checkStatusCode에서 retryable로 판정), 재시도 성공 여부는 error 레벨 로그에는 기록되지 않아 확인이 불가하다.

Technical Analysis#

Code Path#

  • Entry point: PostprocessorService::run()FinalizationService::start()uploadPanosFromVideo()TransferManager::addTask()
  • Upload 실행: TransferManager::transferTask()uploadFile() (transfer.manager.ts:81)
  • Failure point: uploadFile() (transfer.manager.ts:99) — 응답 statusCode가 200이 아닌 502를 수신

uploadFile 메서드는 request 라이브러리의 PUT 요청으로 파일을 스트리밍 업로드한다. 응답이 200이 아니면 error 로그를 출력하고 cupixAuth.handleError(res)를 호출하여 reject한다:

transfer.manager.ts:81-107typescript
uploadFile = (url: string, path: string, headers: any): Promise<void> => new Promise((resolve, reject) => {
    const fileSize =  CPUtils.getFileSize(path);
    headers = { ...headers, 'Content-Length': fileSize };

    logger.debug('TransferManager::uploadFile | begin path: %s, url: %s, size: %d', path, url, fileSize);
    const fileStream = fs.createReadStream(path);
    const sendReq = request.put(url, {
        headers: headers,
        body: fileStream
    });

    sendReq
        .on('response', async res => {
            if (res.statusCode === 200) {
                logger.debug('TransferManager::uploadFile | complete path: %s', path);
                await CPUtils.sleep(100);
                resolve();
            } else {
                logger.error('TransferManager::uploadFile | response path: %s, code: %d, message: %s', path, res.statusCode, res.statusMessage);
                reject(this.cupixAuth.handleError(res));
            }
        })
        .on('error', err => {
            logger.error('TransferManager::uploadFile | path: %s, error: %s', path, JSON.stringify(err));
            reject(this.cupixAuth.handleError(err));
        });
});

CupixAuth::handleError는 에러 객체의 response 속성을 찾으려 하지만, request 라이브러리의 응답 객체는 response가 아닌 최상위에 statusCode를 가지므로 "Undefined response" 경로로 진입한다:

cupix-auth.ts:42-57typescript
handleError = (ec: any): any => {
    const response = ec && CPUtils.isJsonString(ec) ? JSON.parse(ec) : ec.response;
    if (response != undefined) {
        const statusCode = response.status || response.statusCode;
        const requestUriHref = response.config?.url || response.request?.uri?.href;
        const bodyResult = response.data?.result || response.body?.result;
        if (statusCode != undefined && this.setErrorCode != undefined)
            this.setErrorCode(ErrorCode.Tesla.statusCode(statusCode.toString()));
        logger.warn('CupixAuth::handleError | Response statusCode: %d, requestUriHref: %s, body.result: %s',
            statusCode, requestUriHref, JSON.stringify(bodyResult));
    } else {
        logger.warn('CupixAuth::handleError | Undefined response: %s',
            JSON.stringify(ec, Object.getOwnPropertyNames(ec)));
    }
    return ec;
};

재시도 로직: checkStatusCode는 400 < code < 500인 경우만 재시도를 차단하므로, 502는 retryable이다. 최대 5회, 10초 간격으로 재시도한다:

transfer.manager.ts:168-191typescript
private checkStatusCode = (error: any): boolean => {
    if (error?.statusCode != undefined && error.statusCode > 400 && error.statusCode < 500) return false;
    return true;
};

retryTask = (task: BaseTask, error: any): void => {
    if (this.checkStatusCode(error) && task.isRetry()) {
        const idx = this._transferring.indexOf(task);
        this._transferring.splice(idx, 1);
        setTimeout(() => {
            task.retry()
                .then(() => {
                    this.addTask(task);
                })
                .catch(error => {
                    this.retryTask(task, error);
                });
        }, Constants.RetryInterval);
    } else {
        this.failTask(task, error);
    }
};

Cleanup 흐름: cleanUpAnythingRelatedModelrunByMessage에서 run() 성공 후에만 호출된다. 그러나 동일 capture에 대해 다른 호스트들이 이미 cleanup을 실행한 로그가 관찰되었다 (18:16:58, 18:17:19):

base-service.ts:164-171typescript
try {
    await TraceUtils.activateSpan(span, async () => {
        this.setLogMeta(msgObject);
        logger.info('BaseService::runByMessage | id: %d', targetId);
        await this.authenticateByMessage(msgObject);
        await this.run(targetId, msgObject);
        await this.cleanUpAnythingRelatedModel();  // run() 성공 후에만 실행
        await this.deleteByMessage(message);

Log Evidence#

Datadog에서 다음 쿼리들로 검색하였다:

쿼리 1: TransferManager::uploadFile 에러 로그

text
service:cupixworks-capture-postprocessor-agent status:error "TransferManager::uploadFile"

14건의 에러 로그가 발견되었다. 대상 인시던트(capture 684428, 502 Bad Gateway) 1건 외에, eu-central-1에서 500 Internal Server Error 10건, ETIMEDOUT 2건이 동일 시간대에 발생하였다.

쿼리 2: 502 관련 로그

text
service:cupixworks-capture-postprocessor-agent "502"

2건 발견:

text
[2026-04-21T18:19:34.996Z] [error] TransferManager::uploadFile | response path: /tmp/workspace/684428/videos/644427/original/644427_00033.jpg, code: 502, message: Bad Gateway
text
[2026-04-21T18:19:34.997Z] [warn] CupixAuth::handleError | Undefined response: {"statusCode":502,"request":{"method":"PUT"}}

쿼리 3: workspace 684428 관련 로그

text
service:cupixworks-capture-postprocessor-agent (684428 OR 644427)

4건 발견 — cleanup이 3개의 서로 다른 호스트에서 실행됨:

text
[18:16:58.116Z] [info] ip-10-1-37-70    | BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/684428
[18:17:19.394Z] [info] ip-10-1-109-237  | BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/684428
[18:19:34.996Z] [error] ip-10-1-171-116 | TransferManager::uploadFile | ... code: 502 ...
[18:21:11.708Z] [info] ip-10-1-171-116  | BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/684428

쿼리 4: 다른 서비스의 502 에러

text
status:error "502 Bad Gateway"

다른 서비스에서 502 에러가 발견되지 않아, 502의 출처는 Datadog에 로그를 전송하지 않는 로드밸런서 또는 스토리지 게이트웨이 계층으로 추정된다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 업스트림 스토리지/게이트웨이 계층의 일시적 장애로 502 반환 동일 시간대 eu-central-1에서 500/ETIMEDOUT 다수 발생 (14건); 다른 서비스에서 502 로그 없음 → 로드밸런서/프록시 레벨 장애; us-west-2에서 단발성 1건 업스트림 서비스의 직접 로그 확인 불가 Confirmed
H2 동일 capture에 대한 다중 agent 처리로 인한 race condition (workspace cleanup 중 업로드 시도) 3개 호스트에서 workspace 684428 cleanup 로그가 관찰됨; cleanup이 업로드 에러보다 2분 먼저 발생 cleanup은 각 호스트의 로컬 /tmp/workspace/ 경로를 삭제하므로 다른 호스트의 파일에 영향 없음; 502는 서버 측 에러이지 파일 미존재 에러가 아님 (파일 미존재 시 ENOENT 발생) Rejected
H3 presigned URL 만료로 인한 업로드 실패 presigned URL은 유효기간이 있어 만료 가능 URL 만료 시 일반적으로 403 Forbidden 반환, 502가 아님; checkStatusCode는 403을 non-retryable로 처리 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 없음. 이 에러는 업스트림 서비스의 일시적 장애로 인한 것이며, 기존 재시도 로직(5회, 10초 간격)이 이미 존재한다. 1건만 발생했으므로 긴급 수정은 불필요하다.

단기 개선 (1주 이내)#

  • 재시도 결과 로깅 추가 (transfer.manager.ts:173-191): 현재 재시도 시작은 debug 레벨로 로깅되지만, 재시도 성공/실패 최종 결과를 info/warn 레벨로 로깅하면 모니터링 가시성이 향상된다. 최종 failTask 호출 시 총 시도 횟수와 각 시도의 에러 코드를 포함하면 재시도 후에도 실패한 경우를 추적할 수 있다.
  • CupixAuth::handleError 개선 (cupix-auth.ts:42-57): request 라이브러리의 응답 객체는 ec.response가 아닌 ec 자체에 statusCode를 가진다. 현재 "Undefined response" 경로로 빠져 statusCode와 request URL이 구조화되지 않은 JSON dump로만 기록된다. ec.statusCode를 직접 확인하는 분기를 추가하면 보다 유용한 경고 로그를 남길 수 있다.

장기 개선 (재발 방지)#

  • Exponential backoff 도입: 현재 고정 10초 간격 재시도 대신, 지수 백오프(예: 10s, 20s, 40s...)를 적용하면 업스트림 과부하 시 재시도 부하를 줄일 수 있다.
  • 업로드 메트릭 수집: 업로드 성공률, 재시도 횟수, 에러 코드별 분포를 Datadog 메트릭으로 전송하면 업스트림 서비스 상태를 모니터링할 수 있다.

Monitoring#

  • 업로드 실패율 알림:
text
service:cupixworks-capture-postprocessor-agent status:error "TransferManager::uploadFile"

일정 시간 내 threshold 이상 발생 시 알림 설정 (예: 5분 내 10건 이상)

  • 502 에러 전용 모니터:
text
service:cupixworks-capture-postprocessor-agent "code: 502"
  • 멀티 리전 업로드 실패 비교 대시보드: us-west-2, eu-central-1 등 리전별 업로드 에러율을 비교하여 특정 리전의 업스트림 문제를 조기 감지

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial
  • 근거: 단발성 502로 기존 재시도 메커니즘이 동작함. 동일 시간대 eu-central-1에서 더 많은 에러가 발생했으나, 업스트림 일시적 장애로 자체 해소된 것으로 판단된다. 재발 시에도 5회 재시도로 대부분 복구 가능하다.