FloorplanService::downloadFile | path: /tmp/workspace/89075/89075.png, error: {"errno":-104,"code":"
RCA: FloorplanService::downloadFile ECONNRESET
Overview#
What Happened#
2026-06-23 11:19 KST 시점에 cupixworks-any-floorplan-agent(production us-west-2)에서 floorplan id 89075의 원본 PNG 파일을 다운로드하는 도중 TLS socket이 원격에서 강제 종료되어 ECONNRESET (errno -104, read syscall)로 실패했다. SQS 메시지 229cc426-7e7f-4252-bfa4-b51d476f5ddb는 ApproximateReceiveCount: 1이라 자동 재시도 대상이며, 단일 occurrence(점진 발생 패턴이 아님)이다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Error (Node.js ECONNRESET) |
| exception.message | {"errno":-104,"code":"ECONNRESET","syscall":"read"} |
| top_frame | packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:268 |
| runtime | Node.js (request library, TLS read in node:internal/stream_base_commons) |
| env | production us-west-2, tenant cupix |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| floorplan-agent (chrischoi) | 1 | floorplan 89075 1건 처리 실패. SQS 자동 재시도 예정 |
같은 시간대(2026-06-23 02:15~02:55 UTC) 다른 agent 들에서도 ECONNRESET/ETIMEDOUT 다발: TransferManager::downloadFile, TransferManager::uploadFile, ImageCPObject::uploadFile, 그리고 ioredis [ioredis] Unhandled error event: Error: connect ETIMEDOUT (/var/task/refresh_token.js, /var/task/token_save.js) — 토큰 캐시용 Redis와 외부 다운로드 양쪽에 일시적 네트워크 문제가 있었음을 시사.
Timeline#
- 2026-06-23 11:15:42 KST —
BaseService::runByMessage | id: 89075— SQS 메시지 수신, 처리 시작 - 2026-06-23 11:18:46 KST — 같은 agent 노드에서
ioredisconnect ETIMEDOUT(refresh_token.js) 발생 — 동일 시간대 인프라 네트워크 이상 신호 - 2026-06-23 11:19:09 KST —
FloorplanService::downloadFile | path: /tmp/workspace/89075/89075.png, error: {"errno":-104,"code":"ECONNRESET",...}— TLS read 실패 - 2026-06-23 11:19:09 KST —
CupixAuth::handleError | Undefined response: ... read ECONNRESET및BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/89075— workspace 정리 - 2026-06-23 11:19:09 KST —
BaseService::handlingMessageErrors | ... ApproximateReceiveCount: "1"— SQS receive count 1 <MaxReceiveCount이므로 메시지 삭제 안 됨, 재시도 대기
Error Log#
FloorplanService::downloadFile | path: /tmp/workspace/89075/89075.png, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}
Impact#
- Service:
cupixworks-any-floorplan-agent - Team: chrischoi
- 발생 횟수: 1
- 최초 발생: 2026-06-23 11:19 KST
- 최근 발생: 2026-06-23 11:19 KST
영향 범위는 floorplan 89075 1건. BaseService::handlingMessageErrors는 apiErrorObject가 undefined이고 receiveCount(1) < MaxReceiveCount이므로 SQS 메시지를 삭제하지 않는다. 따라서 SQS visibility timeout 만료 후 자동 재시도가 일어나며, 다음 시도에서 네트워크가 정상이면 정상 처리될 가능성이 높다.
Root Cause Summary#
원격 HTTPS endpoint(cpFloorplanSource.downloadUrl)에서 floorplan 원본 PNG를 읽는 도중 TLS 소켓이 peer/네트워크에 의해 reset되어 read ECONNRESET이 발생했다. 이는 application 로직의 결함이 아니라 transient network/upstream connection 문제다. 같은 시간대 동일 production 환경의 다른 agent 들에서도 ECONNRESET/ETIMEDOUT과 ioredis connect ETIMEDOUT이 동반 발생한 것으로 보아, 다운로드 대상(또는 그 앞단의 LB/CDN) 혹은 NAT/네트워크 경로의 일시 장애가 원인일 가능성이 크다. downloadFileWithHeader는 단발 시도이며 자체 재시도 로직이 없으므로, transient한 네트워크 단절이 그대로 에러로 노출된다. SQS 메시지는 receive count 정책에 의해 자동으로 재시도된다.
Technical Analysis#
Code Path#
Entry: SQS 메시지 수신 → BaseService::runByMessage → FloorplanService::run(targetId=89075) → downloadFileWithHeader(downloadUrl, originalFilePath, headers).
run에서 cpFloorplanSource의 download URL을 받아 originalFilePath로 저장한다:
if (cpFloorplan.uploadUrl != undefined
&& cpFloorplan.localFilePath != undefined
&& cpFloorplan.cpFloorplanSource.downloadUrl != undefined
&& cpFloorplan.cpFloorplanSource.originalFilePath != undefined
) {
await this.updateFloorplanState(targetId, TESLA.FloorplanState.Processing);
await this.downloadFileWithHeader(
cpFloorplan.cpFloorplanSource.downloadUrl,
cpFloorplan.cpFloorplanSource.originalFilePath,
cpFloorplan.cpFloorplanSource.headers
);
Failure point: downloadFileWithHeader 내부에서 request.get(url)의 응답 스트림 error 이벤트로 reject. ECONNRESET은 socket read 중 peer가 RST를 보낸 상황으로, response code 200 분기(line 260)도 거치지 못한 채 line 267-270의 'error' handler로 들어왔다:
private downloadFileWithHeader = (url: string, path: string, headers?: any): Promise<void> => new Promise((resolve, reject) => {
this.setErrorCode(ErrorCode.Agent.FailedToDownloadOriginalFiles);
const cupixAuth = this.cupixAuth;
cupixAuth.checkToken()
.then(() => {
logger.debug('FloorplanService::downloadFile | start path: %s, url: %s', path, url);
const fileStream = fs.createWriteStream(path);
const sendReq = request.get(url, {
headers: headers != undefined ? headers : {
'X-CUPIX-AUTH': cupixAuth.accessToken
}
});
fileStream
.on('finish', async () => {
await CPUtils.sleep(100);
fileStream.close();
logger.debug('FloorplanService::downloadFile | complete path: %s, size: %d', path, CPUtils.getFileSize(path));
resolve();
});
sendReq
.on('response', res => {
if (res.statusCode !== 200) {
logger.error('FloorplanService::downloadFile | response path: %s, code: %d, message: %s', path, res.statusCode, res.statusMessage);
reject(cupixAuth.handleError(res));
} else {
sendReq.pipe(fileStream);
}
})
.on('error', err => {
logger.error('FloorplanService::downloadFile | path: %s, error: %s', path, JSON.stringify(err));
reject(cupixAuth.handleError(err));
});
기대 동작: 200 응답을 받고 stream pipe 완료 시 finish → resolve.
실제 동작: TLS read 중 socket이 reset되어 error 이벤트로 진입, reject. 재시도 로직이 함수 내부에 없다(request 라이브러리 default retry 옵션 사용 안 함).
에러는 runByMessages의 try/catch를 거쳐 handlingMessageErrors로 전달된다:
const apiErrorObject = this.getApiErrorToDeleteMessage(error);
if (apiErrorObject != undefined || this.checkReceiveCountToDeleteMessage()) {
try {
errorAndMessage.error = apiErrorObject;
await this.deleteByMessage(this.messageInProcess);
if (this._modelInProcess != undefined && this._modelInProcess.id > 0) await this.updateErrorState(this._modelInProcess);
} catch (error) {
logger.error('BaseService::handlingMessageErrors | Errors in error handling', error);
}
}
}
logger.error('BaseService::handlingMessageErrors | Error and message object - %s', JSON.stringify(errorAndMessage));
ECONNRESET은 HTTP API 4xx/5xx response가 아니므로 getApiErrorToDeleteMessage가 undefined를 반환하고, ApproximateReceiveCount=1 < MaxReceiveCount이므로 메시지를 삭제하지 않는다 → SQS가 visibility timeout 후 자동 재시도한다.
Log Evidence#
Datadog query used:
service:cupixworks-any-floorplan-agent "89075"
floorplan 89075의 처리 흐름(시간순):
2026-06-23 11:15:42 info BaseService::runByMessage | id: 89075
2026-06-23 11:19:09 warn read ECONNRESET
2026-06-23 11:19:09 warn CupixAuth::handleError | Undefined response: {"stack":"Error: read ECONNRESET\n at TLSWrap.onStreamRead (node:internal/stream_base_commons:218:20)\n at TLSWrap.callbackTrampoline (node:internal/async_hooks:130:17)","message":"read ECONNRESET","errno":-104,"code":"ECONNRESET","syscall":"read"}
2026-06-23 11:19:09 error FloorplanService::downloadFile | path: /tmp/workspace/89075/89075.png, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}
2026-06-23 11:19:09 info BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/89075
2026-06-23 11:19:09 error BaseService::handlingMessageErrors | Error and message object - {"error":{"errno":-104,"code":"ECONNRESET","syscall":"read"},"sqsMessage":{"MessageId":"229cc426-7e7f-4252-bfa4-b51d476f5ddb","Attributes":{"ApproximateReceiveCount":"1"}}}
stack trace는 TLSWrap.onStreamRead에서 발생 — TLS 소켓 read 도중 peer가 connection을 RST한 패턴이다.
같은 시간대(±30분) 동일 환경에서 관련 네트워크 에러:
2026-06-23 11:18:46 error [ioredis] Unhandled error event: Error: connect ETIMEDOUT
at Socket.<anonymous> (/var/task/refresh_token.js:159:27553)
2026-06-23 11:15:51 error [ioredis] Unhandled error event: Error: connect ETIMEDOUT
at Socket.<anonymous> (/var/task/refresh_token.js:159:27553)
2026-06-23 10:54:21 error FloorplanService::downloadFile | path: /tmp/workspace/89073/89073.png, error: {"errno":-110,"code":"ETIMEDOUT","syscall":"read"}
2026-06-23 11:42:18 error TransferManager::downloadFile | path: /tmp/workspace/720131/videos/678689/original/678689.insv, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}
2026-06-23 11:35:17 error TransferManager::uploadFile ... ETIMEDOUT
2026-06-23 11:28:49 error TransferManager::uploadFile ... ETIMEDOUT
floorplan-agent 외 transfer 계열 agent 와 토큰 캐시(redis)까지 동시에 ECONNRESET/ETIMEDOUT — 단일 endpoint 결함이 아닌, 호스트 또는 네트워크 경로 차원의 일시 단절로 해석된다.
Datadog query used (전체 환경):
service:cupixworks-any-floorplan-agent "ECONNRESET" OR "ETIMEDOUT" OR "downloadFile"
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Transient network/upstream connection 단절로 다운로드 중 TLS socket이 RST됨 | stack frame이 TLSWrap.onStreamRead에서 시작; 동일 시간대 다른 agent(TransferManager::downloadFile/uploadFile)와 ioredis connect ETIMEDOUT까지 함께 ECONNRESET/ETIMEDOUT 발생; 시도 1회로 단일 occurrence; HTTP status code 분기는 발생 안 함 |
— | Confirmed |
| H2 | downloadUrl 만료/잘못된 URL로 인한 인증 실패 |
— | HTTP 4xx/5xx response가 아니라 socket-level error; res.statusCode !== 200 분기 로그 없음; getApiErrorToDeleteMessage가 undefined 반환(메시지 미삭제) |
Rejected |
| H3 | floorplan source 자체가 손상되어 sharp/exiftool에서 실패 | — | 에러는 다운로드 단계(downloadFileWithHeader)에서 발생, sharp/tileFloorplan까지 진행되지 않음; workspace cleanup 직후 종료 |
Rejected |
| H4 | floorplan-agent process의 file descriptor/메모리 누수로 자체 socket 종료 | — | 단일 occurrence; 같은 시간대 다른 별개 agent/Redis까지 동시 영향; agent 내부 자원 고갈이면 그 agent 단독으로 다발해야 함 | Rejected |
| H5 | 영구적 service outage로 분류해야 함 | — | occurrence_count=1, 단일 floorplan(89075)만 해당; SQS receive count=1이라 자동 재시도 예정; status-board 스코프 svc:cupixworks-any-floorplan-agent::unknown에 active 인시던트 없음 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 별도 조치 불필요. SQS message가
ApproximateReceiveCount=1로 남아 있어 visibility timeout 만료 후 자동 재시도된다. 재시도 시 네트워크가 정상이면 floorplan 89075는 정상 처리된다. - 재시도 후에도 동일 floorplan이
MaxReceiveCount에 도달해 영구 실패로 떨어지면 그때cpFloorplanSource.downloadUrl이 유효한지 수동 확인(packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:67의cpFloorplanSource_downloadUrlinfo 로그).
단기 개선 (1주 이내)#
downloadFileWithHeader(packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:237)에 transient network error(ECONNRESET,ETIMEDOUT,ENOTFOUND,EAI_AGAIN)에 대한 자체 retry-with-backoff을 추가하는 방향 검토. SQS retry는 visibility timeout(분 단위) 단위라서 사용자 체감 latency가 크다. 비교 대상:TransferManager에서도 같은 패턴이 반복되므로 base layer(예:@agents/base)에 공통 HTTP retry util을 두는 것이 좋다.- 현재 코드는 socket-level error를
logger.error로 기록한다. 이런 transient network error는 application bug가 아니므로 retry가 적용된 뒤 최종 실패 시에만error레벨로 남기고, 첫 단절은warn으로 강등하면 RCA/알람 노이즈가 감소한다(같은 시간대 동반 발생한 ETIMEDOUT 다발도 알람을 키운다). cupixAuth.handleError가 socket-level Error를 받으면 "Undefined response" warn으로 처리하는 현 경로(packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:269)에서 retry hook을 함께 호출하도록 한다.
장기 개선 (재발 방지)#
- agent 들이 호출하는 외부 endpoint(특히
cpFloorplanSource.downloadUrl이 가리키는 storage host)에 대한 SLI/SLO 모니터링 추가. ECONNRESET/ETIMEDOUT rate가 임계치 이상이면 인프라 측 조사 트리거. - ioredis
connect ETIMEDOUT(/var/task/refresh_token.js,/var/task/token_save.js)은 같은 시간대 반복 발생 중이므로, 토큰 캐시 Redis 클라이언트 connection 안정성(VPC peering, security group, NAT)을 점검한다. 이것이 외부 다운로드 ECONNRESET과 같은 호스트/AZ 네트워크 이슈일 가능성 — Quick Facts에 표시한us-west-2의 일시 네트워크 이슈 가능성으로 연결되므로 함께 조사할 가치가 있다. - floorplan source download의 일시 실패가 자동 재시도되도록 base-service 차원의 transient error classification(예:
getApiErrorToDeleteMessage와 별도로isTransientNetworkError)을 도입.
Monitoring#
추가/활용할 Datadog 쿼리:
service:cupixworks-any-floorplan-agent status:error "downloadFile"
service:cupixworks-any-floorplan-agent ("ECONNRESET" OR "ETIMEDOUT")
service:cupixworks-any-floorplan-agent "BaseService::handlingMessageErrors"
알림 권장:
- 임의 5분 윈도우에서
cupixworks-any-floorplan-agent의ECONNRESET또는ETIMEDOUT카운트가 임계 이상이면 warn — 단일 floorplan이 아닌 인프라 광역 이슈로 간주. - 같은 시간대 ioredis
connect ETIMEDOUT카운트와 cross-correlate.
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial (즉시 코드 변경 불필요), short-term retry 도입 시 standard
- transient network 원인 + 단일 occurrence + SQS auto-retry 보장 → 사용자 영향 일시적이며 자동 복구 가능