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#
- 2026-06-05 15:33 KST — 사용자가 Insta360 영상
VID_20260605_153312_00_292.insv를 업로드 (POST /upload_credentials,PUT /check_uploading). - 2026-06-08 13:57:15 KST — 3D reconstruction agent가 영상 다운로드 시도 시작 (
[302] GET /api/v1/videos/668420/download). - 2026-06-08 13:57:37 KST — 첫 번째 download 실패:
TLSSocket.socketCloseListener→ECONNRESET. - 2026-06-08 13:58:10 / 13:58:44 / 13:59:17 / 13:59:49 KST — retry 1~4 모두 동일한
ECONNRESET로 실패 (10초 간격 + 다운로드 시간). - 2026-06-08 14:00:23 KST — retry 5(최종) 실패.
failTask | count: 5/5로깅 후 Promise chain이aborted메시지로 reject되어 클러스터 생성.
Error Log#
aborted
대표 에러는 한 단어지만, 동일 capture/video에 대해 다음 형태의 6개 에러가 연속 기록되었다:
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"}
마지막에 다음 로그가 남는다:
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_client의 TLSSocket.socketCloseListener가 Error: 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) - 호출 흐름:
downloadVideoFiles→TransferManager.downloadVideos→DownloadVideosContainer→addTask→transferTask→downloadFile - Failure point:
transfer.manager.ts:76-79(sendReq.on('error', err => ...)— 여기서 TLS abort가 reject로 변환됨)
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한다:
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 내부의 socketCloseListener가 Error: aborted를 던짐 → error 핸들러가 reject.
retry 로직 (base.task.ts:60-66, transfer.manager.ts:170-193):
retry = (): boolean => {
if (this._retryCount < MaxRetries) {
this._retryCount++;
return true;
}
return false;
};
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):
service:cupixworks-capture-3dreconstruction-instance status:error @environment:production "aborted"
caputre 708636 / video 668420 컨텍스트 검증 query:
service:cupixworks-capture-3dreconstruction-instance "708636"
service:cupixworks-api "668420"
핵심 로그(시간순):
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):
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_client가 socketCloseListener에서 발생시키는 메시지다.
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:
service:cupixworks-capture-3dreconstruction-instance status:error "failTask" "count: 5/5"
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 — 별도 프로젝트로 분리 권장.