ES /docs

cupixworks-api TLS connection reset during video download — ECONNRESET

RCA: TransferManager::downloadFile ECONNRESET

Overview#

What Happened#

2026-04-28 05:59:53 UTC에 cupixworks-capture-3dreconstruction-instance 서비스에서 비디오 파일 다운로드 중 TLS 연결이 서버 측에서 끊어지면서 ECONNRESET 에러가 발생했다. 대상 파일은 workspace 34333의 VID_20260427_145057_00_017.insv이며, eu-central-1 리전에서 1회 발생했다. 같은 시간대에 동일 서비스에서 uploadFile 관련 HTTP 500 에러도 5건 추가 발생하여 upstream 서버의 일시적 불안정 상태가 확인된다.

Quick Facts#

Field Value
exception.class Error
exception.message aborted
top_frame node:_http_client:464 (socketCloseListener)
runtime Node.js
env production, eu-central-1

Timeline#

  1. 05:59:02.668ZThreeDReconstruction::init — 새 job 시작 (job id: 92592)
  2. 05:59:02.737ZThreeDReconstruction::run | begin — 실행 시작
  3. 05:59:03.308ZloadVideos | video count: 1 — 비디오 1개 로드
  4. 05:59:53.380ZTransferManager::downloadFile ECONNRESET 에러 발생
  5. 05:59:53.380ZCupixAuth::handleError — Undefined response 로그 기록
  6. 2026-04-28 — Error sweeper 감지 및 RCA 수행

Error Log#

Datadog Logs

text
TransferManager::downloadFile | path: /tmp/workspace/34333/videos/VID_20260427_145057_00_017.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"}

Impact#

  • Service: cupixworks-capture-3dreconstruction-instance
  • Team: crcc-sama
  • 발생 횟수: 1
  • 최초 발생: 2026-04-28T05:59:53.380Z
  • 최근 발생: 2026-04-28T05:59:53.380Z

Root Cause Summary#

Upstream 파일 서버(cupixworks-api)와의 TLS 연결이 비디오 파일 다운로드 중 예기치 않게 종료되어 ECONNRESET 에러가 발생했다. Node.js의 _http_client 모듈 내 socketCloseListener에서 소켓이 aborted 상태로 감지되었으며, 이는 서버 측에서 연결을 먼저 끊었음을 나타낸다. 동일 시간대에 같은 서비스의 uploadFile에서도 HTTP 500과 EPIPE 에러가 5건 발생한 점으로 보아, upstream 서버의 일시적 불안정(네트워크 이슈 또는 서버 과부하)이 근본 원인으로 판단된다. TransferManager의 retry 로직(checkStatusCode)은 ECONNRESET 에러(statusCode 없음)를 retryable로 판단하여 최대 5회 재시도했으나, 모든 시도가 실패한 것으로 보인다.

Technical Analysis#

Code Path#

  • Entry point: app.tsThreeDReconstruction::init()ThreeDReconstruction::run()
  • 비디오 다운로드: three-d-reconstruction-service.ts:229downloadVideoFiles 호출
  • TransferManager 큐 진입: transfer.manager.ts:251-263downloadVideosaddTask
  • 파일 다운로드 실행: transfer.manager.ts:203-204transferTaskdownloadFile
  • Failure point: transfer.manager.ts:76-79.on('error') 핸들러에서 ECONNRESET 수신

downloadFile 메서드는 request npm 패키지를 사용해 HTTP GET 요청으로 파일을 다운로드한다. TLS 소켓이 서버 측에서 종료되면 Node.js _http_clientsocketCloseListeneraborted Error를 발생시키고, 이것이 request 객체의 error 이벤트로 전달된다.

transfer.manager.ts:50-85typescript
private downloadFile = (url: string, path: string, headers: any): Promise<void> => new Promise((resolve, reject) => {
    this.cupixAuth.checkToken()
        .then(() => {
            const fileStream = fs.createWriteStream(path);
            const sendReq = request.get(url, {
                headers: headers
            });

            sendReq
                .on('response', res => {
                    if (res.statusCode === 200) {
                        sendReq.pipe(fileStream);
                    } else {
                        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));
                });
        });
});

에러 발생 후 transferTask의 catch 블록에서 retryTask가 호출된다. checkStatusCode는 ECONNRESET Error 객체에 statusCode가 없으므로 true를 반환하여 재시도 대상으로 판단한다.

transfer.manager.ts:170-193typescript
private checkStatusCode = (error: any): boolean => {
    if (error?.statusCode != undefined && error.statusCode > 400 && error.statusCode < 500) return false;
    return true;  // ECONNRESET은 statusCode 없으므로 항상 true → retryable
};

retryTask = (task: BaseTask, error: any): void => {
    if (this.checkStatusCode(error) && task.retry()) {
        // 10초 후 재시도, 최대 5회
        setTimeout(() => {
            task.renew().then(() => { this.addTask(task); });
        }, Constants.RetryInterval);
    } else {
        this.failTask(task, error);
    }
};

handleError는 ECONNRESET Error에 대해 ec.response가 undefined이므로 "Undefined response" 경로로 warn 로그를 남긴다.

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;
        logger.warn('CupixAuth::handleError | Response statusCode: %d, ...', statusCode, ...);
    } else {
        logger.warn('CupixAuth::handleError | Undefined response: %s',
            JSON.stringify(ec, Object.getOwnPropertyNames(ec)));
    }
    return ec;
};

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-capture-3dreconstruction-instance (TransferManager OR downloadFile OR ECONNRESET)
  시간: 2026-04-28T04:00:00Z ~ 2026-04-28T07:00:00Z
text
service:cupixworks-capture-3dreconstruction-instance status:error
  시간: 2026-04-28T04:00:00Z ~ 2026-04-28T07:00:00Z
text
service:cupixworks-capture-3dreconstruction-instance status:warn
  시간: 2026-04-28T05:00:00Z ~ 2026-04-28T06:30:00Z
text
service:cupixworks-capture-3dreconstruction-instance status:info
  시간: 2026-04-28T05:50:00Z ~ 2026-04-28T06:15:00Z

에러 로그 (downloadFile ECONNRESET — 이 클러스터의 대상):

json
{
  "timestamp": "2026-04-28T05:59:53.380Z",
  "level": "error",
  "message": "TransferManager::downloadFile | path: /tmp/workspace/34333/videos/VID_20260427_145057_00_017.insv, error: {\"stack\":\"Error: aborted\\n    at TLSSocket.socketCloseListener (node:_http_client:464:19)...\",\"message\":\"aborted\",\"code\":\"ECONNRESET\"}"
}

대응하는 warn 로그:

json
{
  "timestamp": "2026-04-28T05:59:53.380Z",
  "level": "warn",
  "message": "CupixAuth::handleError | Undefined response: ECONNRESET (aborted)"
}

같은 시간대의 연관 에러 (upstream 서버 불안정 증거):

text
06:10:01.578Z  error  TransferManager::uploadFile | response code: 500, path: gad.688104_1301980_mesh.cpc
06:11:35.930Z  error  TransferManager::uploadFile | response code: 500, path: gad.688088_1302101.cpc
06:12:00.557Z  error  TransferManager::uploadFile | response code: 500, path: gad.688088_1302101_octree.bin
06:13:05.556Z  error  TransferManager::uploadFile | response code: 500, path: gad.688088_1302098.cpc
06:13:07.605Z  error  TransferManager::uploadFile | EPIPE, path: gad.688088_1302098.cpc

info 로그에서 재시도(retryTask) 관련 로그가 확인되지 않았다. 재시도 로그는 logger.debug 레벨이므로 Datadog에 저장되지 않는다(debug 레벨 미수집).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Upstream 서버 일시적 불안정으로 TLS 연결이 서버 측에서 끊어짐 같은 시간대 uploadFile에서 HTTP 500 에러 5건 + EPIPE 1건 발생 (transfer.manager.ts:76-79). ECONNRESET은 서버 측 연결 종료를 의미. Confirmed
H2 인증 토큰 만료로 서버가 연결을 거부함 checkToken() (transfer.manager.ts:51)이 다운로드 전에 호출되지만, 토큰 갱신 실패 시 다른 에러 경로(checkToken error)로 로깅됨 에러 메시지가 401/403이 아닌 ECONNRESET이며, checkToken 에러 로그 없음. 인증은 05:59:02에 정상 완료 (CupixAuth::setSession) Rejected
H3 대용량 .insv 파일로 인한 다운로드 timeout .insv는 360도 카메라 비디오 파일로 수 GB 가능. request 라이브러리에 별도 timeout 설정이 없음 (transfer.manager.ts:55-57) 에러 코드가 ETIMEDOUT이 아닌 ECONNRESET이며, 에러 메시지가 "aborted"로 timeout이 아닌 연결 종료를 나타냄 Rejected
H4 클라이언트 측 리소스 부족 (메모리/FD 고갈)으로 연결 실패 에러가 서버 측 소켓 종료(socketCloseListener)이며, 같은 시간에 다른 job들이 정상 진행 중 (info 로그 확인) Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 이 에러는 일시적 네트워크/서버 이슈로 인한 단발성 에러이다. 코드 변경 없이도 재시도 로직이 이미 동작하고 있으므로 즉시 수정이 필요한 사항은 없다.
  • 다만, 재시도가 실제로 수행되었는지 확인하려면 debug 레벨 로그(Cupix Watch)에서 retryTask 로그를 확인할 필요가 있다.

단기 개선 — requestaxios 전환 + 공통모듈화#

1. @agents/base에 공통 파일 전송 모듈 추가#

@agents/base 패키지(packages/base/)에 transfer.manager.ts를 추가하여 download/upload 로직을 공통화한다. 이 패키지는 이미 axios@^1.6.5를 의존성으로 가지고 있으며(packages/base/package.json:9), 모든 agent가 @agents/base를 import하고 있으므로 추가 의존성 설정이 불필요하다.

현재 11개 패키지에서 deprecated request@^2.88.2를 사용 중이며, 그 중 4개 capture/tesla agent가 거의 동일한 TransferManager 구현을 가지고 있다:

패키지 TransferManager 위치 download upload
cupix-capture-3d-reconstruction-agent src/manager/transfer.manager.ts request.get + pipe (line 55) request.put + readFileSync (line 89)
cupix-capture-postprocessor-agent src/manager/transfer.manager.ts request.get + pipe (line 49) request.put + createReadStream (line 87)
cupix-capture-preprocessor-agent src/manager/transfer.manager.ts request.get + pipe (line 55) request.put + readFileSync (line 89)
cupix-tesla-room-agent src/manager/transfer.manager.ts request.get + pipe (line 44) request.put + readFileSync (line 78)

나머지 서비스별 request 사용 (potree-service.ts:538, forge-service.ts:427, floorplan-service.ts:244,282, image-cpobject.ts:59,96,142, align.module.ts:350, forge-api.ts:114, thumbnail.api.ts:39, pano.api.ts:169)도 점진적으로 공통모듈로 전환한다.

참고 구현: aerial-map-service의 axios wrapper (applications/aerial-map-service/src/code/src/common/axios.ts)에 이미 retry interceptor + exponential backoff 패턴이 구현되어 있다:

aerial-map-service/src/code/src/common/axios.ts:1-35typescript
import _axios from 'axios';

const RETRY_CODES = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EAI_AGAIN'];
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
const REQUEST_TIMEOUT_MS = 60000;

const axios = _axios.create({ timeout: REQUEST_TIMEOUT_MS });

axios.interceptors.response.use(undefined, async (error) => {
  const config = error.config;
  if (!config) throw error;
  config.__retryCount = config.__retryCount || 0;
  const retryable = RETRY_CODES.includes(error.code)
    || (error.response?.status && error.response.status >= 500);
  if (!retryable || config.__retryCount >= MAX_RETRIES) throw error;
  config.__retryCount += 1;
  const delay = BASE_DELAY_MS * Math.pow(2, config.__retryCount - 1);
  await new Promise((resolve) => setTimeout(resolve, delay));
  return axios(config);
});

2. 공통모듈 설계 방향#

@agents/base에 추가할 공통 전송 모듈의 핵심 기능:

  • downloadFile(url, path, headers): axios의 responseType: 'stream'으로 파일을 stream 다운로드
  • uploadFile(url, path, headers): fs.createReadStream으로 stream upload (현재 3d-reconstruction/preprocessor의 readFileSync 패턴을 stream으로 개선)
  • Retry interceptor: axios interceptor로 retryable 에러(ECONNRESET, ETIMEDOUT, ECONNABORTED, EAI_AGAIN, HTTP 500+)에 대해 exponential backoff 적용
  • Timeout 설정: 명시적 timeout (request 라이브러리에는 timeout 미설정 — transfer.manager.ts:55-57)
  • Queue management: 기존 TransferManager의 병렬 전송 큐(_maxParallelTransfer, addTask, processQueue) 로직도 공통화

기존 각 agent의 TransferManager는 공통모듈의 download/upload 메서드를 호출하도록 리팩토링하며, agent 고유 로직(task container, post-process 등)만 개별 패키지에 남긴다.

3. Exponential backoff 적용#

현재 retry 로직은 고정 10초 간격(@agents/shared-configconstants.ts:9RetryInterval = 10000)으로 최대 5회 재시도한다. 이를 exponential backoff로 변경한다:

현재 (고정 간격):

text
재시도 1: 10초 후 | 재시도 2: 10초 후 | 재시도 3: 10초 후 | 재시도 4: 10초 후 | 재시도 5: 10초 후
총 대기: 50초

개선 (exponential backoff):

text
재시도 1: 1초 후 | 재시도 2: 2초 후 | 재시도 3: 4초 후 | 재시도 4: 8초 후 | 재시도 5: 16초 후
총 대기: 31초 (BASE_DELAY=1000ms 기준)

구현 방법은 두 가지:

  1. axios interceptor 방식 (권장): aerial-map-service처럼 axios response interceptor에서 자동 retry — TransferManager.retryTask의 수동 retry 대체
  2. 기존 retryTask 개선 방식: Constants.RetryInterval 대신 BASE_DELAY * Math.pow(2, task.retryCount - 1) 사용 — 최소 변경

4. Retryable 조건 개선#

현재 checkStatusCode(transfer.manager.ts:170-173)는 401-499만 non-retryable로 판단하고 나머지 모든 에러를 재시도한다. 이를 명시적 retryable 조건으로 변경:

typescript
// 현재: 401-499만 제외, 나머지 모두 retry (너무 관대)
private checkStatusCode = (error: any): boolean => {
    if (error?.statusCode != undefined && error.statusCode > 400 && error.statusCode < 500) return false;
    return true;
};

// 개선: 명시적 retryable 조건
const RETRYABLE_CODES = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EPIPE', 'EAI_AGAIN'];
const isRetryable = (error: any): boolean => {
    if (RETRYABLE_CODES.includes(error?.code)) return true;
    const status = error?.statusCode || error?.response?.status;
    if (status && status >= 500) return true;
    return false;
};

단기 개선 — 로깅#

  • retryTask 로그 레벨을 debug에서 info로 변경하여 Datadog에서도 재시도 과정을 추적할 수 있게 한다. 현재 transfer.manager.ts:179logger.debuglogger.info로 변경.
  • failTask 로그(transfer.manager.ts:167)에 에러 상세(error.code, error.message)도 함께 로깅.

장기 개선 — 전체 request 제거#

  • 11개 패키지의 request 의존성을 단계적으로 제거. 공통모듈 전환 후 pnpm-workspace.yaml:7request: ^2.88.2 catalog 항목도 삭제.
  • @agents/api 패키지의 request 사용 (forge-api.ts:114, thumbnail.api.ts:39, pano.api.ts:169)도 axios로 전환 — 이 패키지는 이미 axios@^1.7.0을 의존성으로 가지고 있음 (packages/api/package.json:10).
  • 에러 심각도 재평가: 일시적 네트워크 에러(ECONNRESET, EPIPE)가 재시도 후 복구되는 경우 error 대신 warn 레벨로 로깅하는 것이 적절하다. 최종 실패(모든 재시도 소진) 시에만 error로 로깅하도록 변경을 검토한다.

Monitoring#

  • 재시도 패턴 모니터링:
text
service:cupixworks-capture-3dreconstruction-instance "TransferManager::failTask"
  • 네트워크 에러 트렌드:
text
service:cupixworks-capture-3dreconstruction-instance status:error (ECONNRESET OR EPIPE OR ETIMEDOUT)
  • upstream 서버 500 에러 추이:
text
service:cupixworks-capture-3dreconstruction-instance "uploadFile" "500"

Risk Assessment#

  • Risk level: low (에러 자체), medium (fix 범위 — requestaxios 전환은 11개 패키지에 영향)
  • 예상 복잡도: medium — 공통모듈 추출 + axios 전환 + exponential backoff 적용. 단, 각 agent의 TransferManager 구조가 거의 동일하므로 리팩토링 난이도는 높지 않다.
  • 영향 범위: cupixworks/applications/agents/ 모노레포 내 11개 패키지, 12개 소스 파일, 18개 call site

Revision History#

Revision 2#

Feedback: request로 파일 받는 곳 모두 확인 + axios 전환 가능성 검토. 공통모듈에 파일 다운로드 기능을 넣고 사용하도록. Retriable HTTP code에 exponential backoff 적용.

판정:

피드백 항목 판정 근거
request 사용처 전수 조사 + axios 전환 가능성 수용 cupixworks/applications/agents/ 모노레포 내 11개 패키지(12개 파일, 18개 call site)에서 request@^2.88.2 사용 확인. @agents/base(packages/base/package.json:9)에 이미 axios@^1.6.5, @agents/api(packages/api/package.json:10)에 axios@^1.7.0이 존재하여 추가 의존성 없이 전환 가능. aerial-map-service/src/code/src/common/axios.ts에 retry + exponential backoff 참조 구현도 이미 존재.
공통모듈에 파일 다운로드 기능 추가 수용 @agents/base(packages/base/src/index.ts)가 이미 8개 공통 매니저를 export하는 공유 패키지 역할. 4개 agent(3d-reconstruction, postprocessor, preprocessor, room)의 TransferManager가 거의 동일한 download/upload 패턴(request.get + pipe, request.put). @agents/base에 공통 TransferManager를 추가하고 packages/base/src/index.ts에서 export하면 모든 agent에서 즉시 사용 가능.
Retriable HTTP code에 exponential backoff 적용 수용 현재 retry는 @agents/shared-configconstants.ts:9에 정의된 고정 10초 간격(RetryInterval = 10000), 최대 5회(constants.ts:8MaxRetries = 5). aerial-map-service/src/code/src/common/axios.ts:25BASE_DELAY_MS * Math.pow(2, retryCount - 1) 패턴이 이미 검증됨. axios interceptor 방식으로 전환하면 TransferManager.retryTask의 수동 retry 로직을 대체할 수 있음.

변경 사항:

  • Fix Recommendation 섹션 전면 재구성: 기존 "장기 개선"에 한 줄로 언급되던 axios 전환을 단기 개선의 핵심 항목으로 승격
  • request 사용처 전수 조사 결과 (11개 패키지, 12개 파일, 18개 call site) 상세 테이블 추가
  • 공통모듈(@agents/base) 설계 방향 및 핵심 기능 목록 추가
  • Exponential backoff 현재 vs 개선 비교 및 구현 방법 2가지 제시
  • checkStatusCode retryable 조건 개선안 추가
  • Risk Assessment 복잡도를 trivial → medium으로 상향

추가 조사 내용:

  • cupixworks/applications/agents/ 모노레포 전체에서 request import 전수 조사 (Grep: import.*request)
  • @agents/base 패키지 구조 분석 (packages/base/src/index.ts, packages/base/package.json)
  • @agents/api 패키지의 axios/request 이중 의존성 확인 (packages/api/package.json)
  • @agents/shared-config의 retry 상수 확인 (packages/shared-config/src/constants.ts:8-9)
  • aerial-map-service의 axios wrapper + exponential backoff 참조 구현 분석 (applications/aerial-map-service/src/code/src/common/axios.ts)
  • pnpm-workspace.yaml:7request catalog 항목 확인
  • 4개 agent의 TransferManager 코드 비교 (3d-reconstruction, postprocessor, preprocessor, room)
  • 추가 request 사용 서비스: potree(potree-service.ts:538), forge(forge-service.ts:427), floorplan(floorplan-service.ts:244,282), thumbnail(image-cpobject.ts:59,96,142), singleshot(align.module.ts:350)