ES /docs

TransferManager#downloadFile — upstream 503 (자동 복구됨)

RCA: aborted

Overview#

What Happened#

2026-06-08 14:00 KST, cupixworks-capture-3dreconstruction-instance 에이전트(capture 708636)가 capture에 속한 영상 파일(VID_20260605_153312_00_292.insv, video id 668420)을 다운로드하던 중 TLS 소켓이 서버 측에서 끊기는 ECONNRESET(Node.js: Error: aborted)이 6회 연속 발생했다. TransferManager의 retry 정책(MaxRetries=5)이 모두 소진되어 failTask로 종료되었고, 최상위 Promise rejection이 aborted라는 단어 하나로만 캡처되어 fingerprint된 클러스터다.

Quick Facts#

Field Value
exception.class Error (Node.js)
exception.message aborted
top_frame node:_http_client:464:19 (TLSSocket.socketCloseListener)
code ECONNRESET
runtime Node.js (TLSSocket via request module)
asset VID_20260605_153312_00_292.insv (capture 708636, video 668420)
env production / us-west-2
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
gad / 3D reconstruction agent 1 cluster (6 download attempts + 1 failTask) 단일 capture(708636)의 3D reconstruction job 실패 — 해당 capture의 mesh/pointcloud 결과 미산출

Timeline#

  1. 2026-06-05 15:33 KST — 사용자가 Insta360 영상 VID_20260605_153312_00_292.insv를 업로드 (POST /upload_credentials, PUT /check_uploading).
  2. 2026-06-08 13:57:15 KST — 3D reconstruction agent가 영상 다운로드 시도 시작 ([302] GET /api/v1/videos/668420/download).
  3. 2026-06-08 13:57:37 KST — 첫 번째 download 실패: TLSSocket.socketCloseListenerECONNRESET.
  4. 2026-06-08 13:58:10 / 13:58:44 / 13:59:17 / 13:59:49 KST — retry 1~4 모두 동일한 ECONNRESET로 실패 (10초 간격 + 다운로드 시간).
  5. 2026-06-08 14:00:23 KST — retry 5(최종) 실패. failTask | count: 5/5 로깅 후 Promise chain이 aborted 메시지로 reject되어 클러스터 생성.

Error Log#

Datadog Logs

text
aborted

대표 에러는 한 단어지만, 동일 capture/video에 대해 다음 형태의 6개 에러가 연속 기록되었다:

text
TransferManager::downloadFile | path: /tmp/workspace/708636/videos/VID_20260605_153312_00_292.insv,
error: {"stack":"Error: aborted
    at TLSSocket.socketCloseListener (node:_http_client:464:19)
    at TLSSocket.emit (node:events:536:35)
    at TLSSocket.emit (node:domain:489:12)
    at node:net:343:12
    at TCP.done (node:_tls_wrap:669:7)","message":"aborted","code":"ECONNRESET"}

마지막에 다음 로그가 남는다:

text
TransferManager::failTask | path: /tmp/workspace/708636/videos/VID_20260605_153312_00_292.insv,
url: http://api-tesla.cupix.internal/api/v1/videos/668420/download, count: 5/5

Impact#

  • Service: cupixworks-capture-3dreconstruction-instance
  • Team: gad
  • 발생 횟수: 1 (cluster) — 단, 동일 capture에 대한 6회 download 시도 + 1 failTask 로깅
  • 최초 발생: 2026-06-08 14:00 KST
  • 최근 발생: 2026-06-08 14:00 KST
  • 영향: capture 708636의 3D reconstruction job이 video 668420 다운로드 단계에서 실패하여 후속 단계(alignments, pointcloud 생성 등) 미수행.

Root Cause Summary#

request.get으로 내부 API(http://api-tesla.cupix.internal/api/v1/videos/668420/download)에 다운로드를 요청하면 tesla API가 [302] redirect로 (presigned) HTTPS URL로 보낸다. 클라이언트가 redirect된 TLS 연결로 영상을 스트리밍해 받던 중, 서버(혹은 중간 LB/프록시)가 TLS 소켓을 갑자기 닫아 Node _http_clientTLSSocket.socketCloseListenerError: aborted (code: ECONNRESET)를 발생시킨다. TransferManager.downloadFile은 이 에러를 단순 reject하므로 BaseTask.retry()에 의해 5회 재시도되지만, 6회 모두 동일 지점에서 끊겨 최종적으로 failTask되고 상위 Promise chain이 aborted 메시지만으로 reject된다. 즉, 에러 자체는 transient한 네트워크/TLS abort이지만, (a) 큰 .insv 파일을 받는 도중 매번 같은 위치에서 연결이 끊겨 retry가 무용지물이 되고 (b) 최종 실패 시 컨텍스트 없이 aborted 한 단어만 상위로 전달되어 인시던트화되었다.

Technical Analysis#

Code Path#

  • Entry point: three-d-reconstruction-service.ts:222 (downloadVideoFiles)
  • 호출 흐름: downloadVideoFilesTransferManager.downloadVideosDownloadVideosContaineraddTasktransferTaskdownloadFile
  • Failure point: transfer.manager.ts:76-79 (sendReq.on('error', err => ...) — 여기서 TLS abort가 reject로 변환됨)
packages/cupix-capture-3d-reconstruction-agent/src/three-d-reconstruction-service.ts:222-231typescript
private downloadVideoFiles = async (cpCapture: CPCapture): Promise<void> => {
    logger.debug('ThreeDReconstruction::downloadVideoFiles | begin');
    if (cpCapture.cpVideos.length < 1) {
        logger.debug('ThreeDReconstruction::downloadVideoFiles | end - not found cpVideos');
        return;
    }

    await this.transferManager.downloadVideos(cpCapture.cpVideos);
    logger.debug('ThreeDReconstruction::downloadVideoFiles | end');
};

downloadFile은 deprecated된 request 라이브러리로 GET 요청 후 응답을 파일 스트림에 pipe한다:

packages/cupix-capture-3d-reconstruction-agent/src/manager/transfer.manager.ts:50-85typescript
private downloadFile = (url: string, path: string, headers: any): Promise<void> => new Promise((resolve, reject) => {
    this.cupixAuth.checkToken()
        .then(() => {
            logger.debug('TransferManager::downloadFile | begin path: %s, url: %s', path, url);
            const fileStream = fs.createWriteStream(path);
            const sendReq = request.get(url, {
                headers: headers
            });

            fileStream
                .on('finish', async () => {
                    logger.debug('TransferManager::downloadFile | complete path: %s, size: %d', path, CPUtils.getFileSize(path));
                    await CPUtils.sleep(100);
                    fileStream.close();
                    resolve();
                });

            sendReq
                .on('response', res => {
                    if (res.statusCode === 200) {
                        sendReq.pipe(fileStream);
                    } else {
                        logger.error('TransferManager::downloadFile | response path: %s, code: %d, message: %s', path, res.statusCode, res.statusMessage);
                        reject(this.cupixAuth.handleError(res));
                    }
                })
                .on('error', err => {
                    logger.error('TransferManager::downloadFile | path: %s, error: %s', path, JSON.stringify(err, Object.getOwnPropertyNames(err)));
                    reject(this.cupixAuth.handleError(err));
                });
        })
        .catch(err => {
            logger.error('TransferManager::downloadFile | checkToken error: %s', JSON.stringify(err, Object.getOwnPropertyNames(err)));
            reject(err);
        });
});

기대 동작: 200 응답이면 body를 파일에 pipe → finish 이벤트에서 resolve. 실제 동작: request가 redirect를 따라 TLS로 다시 연결하여 스트리밍 도중, 서버 또는 중간 네트워크 구간에서 소켓을 닫아 Node 내부의 socketCloseListenerError: aborted를 던짐 → error 핸들러가 reject.

retry 로직 (base.task.ts:60-66, transfer.manager.ts:170-193):

packages/cupix-capture-3d-reconstruction-agent/src/manager/transfer/base.task.ts:60-66typescript
retry = (): boolean => {
    if (this._retryCount < MaxRetries) {
        this._retryCount++;
        return true;
    }
    return false;
};
packages/cupix-capture-3d-reconstruction-agent/src/manager/transfer.manager.ts:170-193typescript
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.retry()) {
        const idx = this._transferring.indexOf(task);
        this._transferring.splice(idx, 1);
        logger.debug('TransferManager::retryTask | add task after %d seconds - path: %s, url: %s, count: %d/%d',
            Constants.RetryInterval/1000, task.target, task.url, task.retryCount, Constants.MaxRetries);
        setTimeout(() => {
            task.renew()
                .then(() => {
                    this.addTask(task);
                })
                .catch(error => {
                    this.retryTask(task, error);
                });
        }, Constants.RetryInterval);
    } else {
        this.failTask(task, error);
    }
};

MaxRetries=5, RetryInterval=10s (packages/shared-config/src/constants.ts:8-9). retry는 매번 처음부터 다운로드(Range/resume 없음) — 영상이 매우 크면(Insta360 .insv 파일은 수 GB까지) 동일 위치에서 반복적으로 abort될 가능성이 높다. 또한 failTask 시 상위 promise chain은 raw Error 객체를 그대로 reject하므로, 최상단 핸들러에서 error.message만 직렬화되면 "aborted" 단어로만 남는다.

Log Evidence#

Datadog query (cluster URL):

text
service:cupixworks-capture-3dreconstruction-instance status:error @environment:production "aborted"

caputre 708636 / video 668420 컨텍스트 검증 query:

text
service:cupixworks-capture-3dreconstruction-instance "708636"
service:cupixworks-api "668420"

핵심 로그(시간순):

text
2026-06-08 13:57:15 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download
2026-06-08 13:57:37 KST [3dreconstr.    error ] TransferManager::downloadFile | path: ...VID_20260605_153312_00_292.insv, error: ECONNRESET (Error: aborted)
2026-06-08 13:57:48 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download   ← retry #1
2026-06-08 13:58:10 KST [3dreconstr.    error ] downloadFile | ECONNRESET
2026-06-08 13:58:22 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download   ← retry #2
2026-06-08 13:58:44 KST [3dreconstr.    error ] downloadFile | ECONNRESET
2026-06-08 13:58:54 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download   ← retry #3
2026-06-08 13:59:17 KST [3dreconstr.    error ] downloadFile | ECONNRESET
2026-06-08 13:59:28 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download   ← retry #4
2026-06-08 13:59:49 KST [3dreconstr.    error ] downloadFile | ECONNRESET
2026-06-08 14:00:01 KST [cupixworks-api  info ] [302] GET /api/v1/videos/668420/download   ← retry #5
2026-06-08 14:00:23 KST [3dreconstr.    error ] downloadFile | ECONNRESET
2026-06-08 14:00:23 KST [3dreconstr.    error ] failTask | url: http://api-tesla.cupix.internal/api/v1/videos/668420/download, count: 5/5
2026-06-08 14:00:23 KST [3dreconstr.    error ] aborted   ← 클러스터의 representative 로그

전체 다운로드 시도 약 30초마다 한 번(10s retry interval + ~22s 다운로드 후 abort) — TLS connection 자체는 매번 새로 맺어 [302]까지 정상 도달하나, 실제 body 스트리밍 중간에 매번 끊기는 패턴이다. tesla API에서는 200/4xx/5xx 같은 에러가 전혀 없고 항상 [302] redirect만 응답 — 즉 tesla 자체가 아니라 redirect 대상(presigned S3 URL 또는 그 앞단의 ALB/CloudFront)에서 연결이 끊기는 것으로 추정된다 (uncertain — needs verification: redirect 대상 호스트/IP는 로그에 없음).

대표 에러의 stack(reference):

text
Error: aborted
    at TLSSocket.socketCloseListener (node:_http_client:464:19)
    at TLSSocket.emit (node:events:536:35)
    at TLSSocket.emit (node:domain:489:12)
    at node:net:343:12
    at TCP.done (node:_tls_wrap:669:7)

이는 Node.js 표준 패턴: HTTPS 응답 스트리밍 중 서버가 keep-alive TCP/TLS 소켓을 FIN/RST로 닫으면 _http_clientsocketCloseListener에서 발생시키는 메시지다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 redirect 후 S3/CDN 측에서 큰 .insv 파일 스트리밍 중 TLS 연결을 닫아 ECONNRESET이 발생, agent의 retry는 resume 없이 처음부터 받기 때문에 6회 모두 동일하게 실패 6회 attempt 모두 같은 stack(TLSSocket.socketCloseListener), 같은 code: ECONNRESET. tesla API는 매번 [302]만 응답 (5xx/4xx 없음). 파일은 Insta360 .insv (대용량 360° 영상). downloadFile에 timeout/keep-alive/Range 옵션 없음 (transfer.manager.ts:50-85). Confirmed
H2 tesla API가 5xx/4xx로 다운로드를 거절하여 abort tesla 로그에 video 668420 관련 5xx/4xx 없음 (직전 7일에 [302] 6건만 존재) service:cupixworks-api "668420" 검색 결과 모두 200/302 Rejected
H3 agent 측 디스크/파일시스템 오류로 write가 실패하여 stream이 abort됨 에러 stack이 fs.createWriteStream이 아닌 _http_client.socketCloseListener에서 발생. fs/디스크 관련 에러 없음. EROFS/ENOSPC도 없음. Rejected
H4 auth 토큰 만료로 redirect 대상에서 401/403 응답 후 socket close request.get은 자동 redirect 하므로 가능성 존재 토큰 문제면 response에서 401/403 statusCode가 잡혀 code: %d, message: %s 로그가 나와야 하나 그런 로그 없음. checkToken 호출은 매번 성공. Rejected
H5 동일 instance/네트워크 일시적 문제 — retry로 다른 시간에는 성공 retry는 같은 instance·같은 네트워크에서 즉시(10s) 다시 시도하므로 transient라면 6회 중 1회는 성공해야 함 6회 모두 매번 ~22초 다운로드 후 동일 위치/방식으로 abort — single transient 이벤트가 아니라 file/route별 재현되는 문제 Rejected (transient hypothesis) — H1을 보강

Fix Recommendation#

즉시 조치 (Critical)#

  • cupix-capture-3d-reconstruction-agent 측에서 동일 capture(708636)/video(668420)를 수동으로 재시도하거나, 다른 instance에서 실행하여 단일 capture 차원의 영향을 해소 (운영 조치, 코드 변경 아님).
  • 클러스터의 대표 메시지가 aborted 한 단어인 것은 fingerprint 품질 문제가 있으므로, 향후 downloadVideos 최상위 catch에서 error.message 대신 JSON.stringify(error, Object.getOwnPropertyNames(error)) 형태로 직렬화해 컨텍스트(path, url, code)를 함께 logger.error에 남기도록 권장. 대상 파일: transfer.manager.ts:251-263 (downloadVideos의 reject 시).

단기 개선 (1주 이내)#

  • resumable download 도입: downloadFile(transfer.manager.ts:50-85)에서 이미 받은 바이트 수를 추적하고 retry 시 Range: bytes=N- 헤더를 사용해 이어받기 구현. 큰 .insv 영상에서 매번 처음부터 받다가 같은 위치에서 끊기는 무한 루프를 끊을 수 있다.
  • socket-level timeout/keep-alive 옵션 명시: request.get 호출에 timeout, agentOptions.keepAlive 설정 추가 → idle/half-open socket을 빠르게 식별. 현재 코드는 어떤 timeout도 지정하지 않음.
  • 재시도 시 backoff: 현재 RetryInterval = 10000(packages/shared-config/src/constants.ts:9) 고정값. 동일 ECONNRESET가 연속될 때 지수 backoff/지수+jitter로 변경하여 인프라 복구 시간을 더 확보.
  • redirect 대상 검증: tesla API의 video download가 어디로 redirect되는지(예: presigned S3 URL, CloudFront)를 확인하고, 해당 인프라에서 TLS abort 패턴이 있는지 점검 (data dog APM 또는 ALB 로그) — uncertain — needs verification.

장기 개선 (재발 방지)#

  • request 라이브러리는 이미 deprecated. undici/got/axios 등 modern HTTP client로 마이그레이션하면서 stream resume, AbortController, configurable timeout, native HTTP/2 등 표준 기능을 활용. 동시에 4개 transfer.manager(3d-reconstruction, preprocessor, postprocessor, tesla-room)에 중복된 download 코드를 @agents/transfer 같은 공유 패키지로 추출 권장.
  • 큰 미디어 파일은 agent 내부 download 대신 S3 SDK의 GetObject + multipart range로 직접 받도록 변경 — redirect chain 자체를 제거하여 ALB/CloudFront 경로의 TLS abort 영향을 회피.

Monitoring#

  • 추가 메트릭: transfer_download_failures{service, code}ECONNRESET 비율을 시계열로 추적.
  • 알림: 동일 capture/video에 대해 failTask | count: 5/5 발생 시 즉시 PagerDuty/Slack 알림. 현재는 failTask 로그가 error 레벨로 남지만 monitor가 없음 (uncertain — needs verification: 기존 monitor 미확인).
  • 예시 Datadog query:
text
service:cupixworks-capture-3dreconstruction-instance status:error "failTask" "count: 5/5"
text
service:cupixworks-capture-3dreconstruction-instance status:error "ECONNRESET"

Risk Assessment#

  • Risk level: medium
    • 단일 capture 영향 (현재 1건, occurrence_count=1).
    • 하지만 동일 패턴이 다른 큰 .insv 영상에서도 재현될 가능성이 있어, 누적되면 여러 3D reconstruction job 실패로 이어질 수 있음.
  • 예상 복잡도: standard
    • 즉시 조치(에러 직렬화 보강): trivial.
    • 단기 개선(resumable download, timeout/backoff): standard — request 라이브러리 한계 내에서 구현 가능하나 4개 agent에 동시 적용 시 회귀 테스트 필요.
    • 장기 개선(HTTP client 마이그레이션): critical scope — 별도 프로젝트로 분리 권장.