ES /docs

ImageCPObject::downloadFile | path: /tmp/workspace/88774/88774.png, error: {"errno":-104,"code":"ECO

RCA: ImageCPObject::downloadFile ECONNRESET on floorplan 88774

Overview#

What Happened#

cupixworks-any-thumbnail-agent가 SQS 메시지로 받은 Floorplan 88774의 원본 이미지를 다운로드하던 중, 원격지에서 TCP 연결을 끊어 ECONNRESET (errno -104)이 발생했다. 다운로드 시작(16:20:58 KST)에서 에러 발생(16:24:24 KST)까지 약 3분 26초가 경과한 점으로 보아, redirect된 스토리지 응답이 idle 상태에서 끊긴 것이다. 동일 시간대(15분 내) 같은 서비스에서 ETIMEDOUT, "Internal Server Error" 등 비슷한 네트워크/업스트림 장애가 다수 관측된다.

Quick Facts#

Field Value
exception.class ECONNRESET (Node.js system error)
exception.message read ECONNRESET
top_frame TLSWrap.onStreamRead (node:internal/stream_base_commons:218:20)
failure point packages/cupix-tesla-thumbnail-agent/src/model/common/image-cpobject.ts:82-85
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
gad / thumbnail-agent (Floorplan) 1 (이 클러스터) Floorplan 88774의 thumbnail 생성 실패 — SQS 메시지가 DLQ로 이동했을 가능성
gad / thumbnail-agent (전반) 동일 시간대 다수 (ETIMEDOUT, 500, "undefined response") 다른 floorplan/asset 의 thumbnail 생성도 같은 시간대에 함께 실패

Timeline#

  1. 2026-06-19 16:20:58 KSTBaseService::runByMessage | id: 88774 — SQS 메시지 수신, 처리 시작
  2. 2026-06-19 16:20:58 KSTThumbnailService::newModel | model id: 88774, type: Floorplan
  3. 2026-06-19 16:20:58 KSTImageCPObject::downloadOriginal | url: http://api-tesla.cupix.internal/api/v1/floorplans/88774/download, path: /tmp/workspace/88774/88774.png
  4. 2026-06-19 16:21:00 KSTcupixworks-api[302] GET /api/v1/floorplans/88774/download로 응답 (스토리지로 redirect)
  5. 2026-06-19 16:24:24 KSTread ECONNRESET (warn) → CupixAuth::handleError | Undefined response (warn) → ImageCPObject::downloadFile | path: /tmp/workspace/88774/88774.png, error: {...ECONNRESET} (error) → BaseService::handlingMessageErrors (error)
  6. 2026-06-19 16:26:01 KST — 같은 서비스에서 다른 floorplan(88747)에 대한 ETIMEDOUT 발생 — 일시적 네트워크/업스트림 불안정 패턴 지속
  7. 2026-06-19 16:26:01 KST — Status board 인시던트 2026-06-19-svc-cupixworks-any-thumbnail-agent-3 자동 resolve

Error Log#

Datadog Logs

text
ImageCPObject::downloadFile | path: /tmp/workspace/88774/88774.png, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}

Impact#

  • Service: cupixworks-any-thumbnail-agent
  • Team: gad
  • 발생 횟수: 1
  • 최초 발생: 2026-06-19 16:24:24 KST
  • 최근 발생: 2026-06-19 16:24:24 KST

Root Cause Summary#

원본 이미지 다운로드 중 TCP 연결이 피어에 의해 강제 종료되어(ECONNRESET) thumbnail 생성이 실패했다. ImageCPObject.downloadFilerequest.get()을 사용해 원본 URL에서 파일을 스트리밍 다운로드하는데, retry 로직이 statusCode가 있는 HTTP 5xx 에러에만 동작하고 (CupixAuth.retryablee.statusCode > 500만 검사), 네트워크 레벨 에러(ECONNRESET, ETIMEDOUT)는 즉시 reject되어 SQS 메시지가 실패 처리된다. 따라서 일시적 네트워크 끊김에 대해 자동 복구가 불가능한 구조이다. 다운로드 URL은 /api/v1/floorplans/88774/download이며 API가 302로 외부 스토리지(presigned URL)로 redirect하므로, 실제 끊긴 연결은 스토리지 측이다.

Technical Analysis#

Code Path#

  • Entry point: SQS message handler → ThumbnailService::newModel | model id: 88774, type: Floorplan
  • Download orchestration: ImageCPObject.downloadOriginal (packages/cupix-tesla-thumbnail-agent/src/model/common/image-cpobject.ts:164-181) — 파일이 없으면 downloadFile을 호출
  • Failure point: ImageCPObject.downloadFilerequest.get(...) 'error' 핸들러 (image-cpobject.ts:82-85) — 스트리밍 중 ECONNRESET이 발생하면 reject
packages/cupix-tesla-thumbnail-agent/src/model/common/image-cpobject.ts:53-91typescript
private downloadFile = (url: string, filePath: string): Promise<void> => new Promise((resolve, reject) => {
    const cupixAuth = this.cupixAuth;
    cupixAuth.checkToken()
        .then(() => {
            logger.debug('ImageCPObject::downloadFile | start path: %s', filePath, url);
            const fileStream = fs.createWriteStream(filePath);
            const sendReq = request.get(url, {
                headers: {
                    'X-CUPIX-AUTH': cupixAuth.accessToken
                }
            });
            // ...
            sendReq
                .on('response', res => { /* statusCode 검사 */ })
                .on('error', err => {
                    logger.error('ImageCPObject::downloadFile | path: %s, error: %s', filePath, JSON.stringify(err));
                    reject(cupixAuth.handleError(err));
                });
        })
        .catch((err) => { /* token error */ });
});

downloadFile 자체에는 retry/backoff가 없다. 외부 wrapper(CupixAuth.retryable)에서만 재시도가 일어나는데, 그 조건은 statusCode 기반이다.

packages/agents/api/src/authentication/cupix-auth.ts:59-69typescript
retryable = <T>(f: () => Promise<T>, retries?: number): Promise<T> => new Promise((resolve, reject) => {
    const _retries = retries != undefined ? retries : 0;
    f()
        .then(response => resolve(response))
        .catch(e => {
            if (e && e.statusCode > 500 && _retries < Constants.MaxRetries) {
                // ...retry...
            }
        });
});

기대 동작: 일시적 네트워크 끊김(ECONNRESET/ETIMEDOUT)도 backoff 후 재시도되어야 함. 실제 동작: e.statusCodeundefined이므로 undefined > 500false → 재시도 없음 → SQS 실패.

CupixAuth.handleError는 statusCode가 없을 때 "Undefined response"를 warn으로 남기고 그대로 ec를 반환하므로, 네트워크 에러를 분류해 더 적합한 처리(재시도, 다른 backoff 정책)로 이어지지 않는다.

packages/agents/api/src/authentication/cupix-auth.ts:42-57typescript
handleError = (ec: any): any => {
    const response = ec && CPUtils.isJsonString(ec) ? JSON.parse(ec) : ec.response;
    if (response != undefined) {
        // ... statusCode 분기 ...
    } else {
        logger.warn('CupixAuth::handleError | Undefined response: %s', JSON.stringify(ec, Object.getOwnPropertyNames(ec)));
    }
    return ec;
};

Log Evidence#

Datadog query (재현용):

text
service:cupixworks-any-thumbnail-agent "ECONNRESET"
text
service:cupixworks-any-thumbnail-agent "88774"

같은 trace의 핵심 로그(시간순):

text
2026-06-19 16:20:58  info   BaseService::runByMessage | id: 88774
2026-06-19 16:20:58  info   ThumbnailService::newModel | model id: 88774, type: Floorplan
2026-06-19 16:20:58  info   ImageCPObject::downloadOriginal | url: http://api-tesla.cupix.internal/api/v1/floorplans/88774/download, path: /tmp/workspace/88774/88774.png
2026-06-19 16:21:00  info   [302] GET /api/v1/floorplans/88774/download (Api::V1::FloorplansController#download_single_resource)   ← cupixworks-api
2026-06-19 16:24:24  warn   read ECONNRESET
2026-06-19 16:24:24  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-19 16:24:24  error  ImageCPObject::downloadFile | path: /tmp/workspace/88774/88774.png, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}
2026-06-19 16:24:24  error  BaseService::handlingMessageErrors | Error and message object - {"error":{"errno":-104,"code":"ECONNRESET","syscall":"read"},"sqsMessage":{"MessageId":"ce064437-a2f4-441e-8483-b9cf6f6ac0ca","Attributes":{"ApproximateReceiveCount":"1"}}}

스택 프레임 TLSWrap.onStreamRead은 TLS read 도중(즉, 302 redirect 후 HTTPS 스토리지에서 본문을 받는 중) 끊겼음을 나타낸다. cupixworks-api 측 로그는 302 응답까지만 남고 본문 전송은 외부 스토리지 영역이다.

같은 서비스 동일 시간대의 유사 패턴(클러스터링 근거):

text
2026-06-19 16:26:01  error  ImageCPObject::downloadFile | path: /tmp/workspace/88747/88747.png, error: {"errno":-110,"code":"ETIMEDOUT","syscall":"read"}
2026-06-19 16:22:55  error  ImageCPObject::uploadFile  | response ... code: 500, message: Internal Server Error
2026-06-19 16:16:08~16:22:55  error  ImageCPObject::uploadFile | "undefined response" 다수

Status board 기록:

text
incident: 2026-06-19-svc-cupixworks-any-thumbnail-agent-3 (resolved)
started_at:    2026-06-19T07:22:55.572Z
resolved_at:   2026-06-19T07:26:01.914Z
clusters: [964edbe6..., 258d1ea0... (this), d1ff47b4...]

직전 24시간 내에 동일 서비스의 같은 incident scope가 4번 자동 발생/해소되었다(...-1, -2, -3, 그리고 06-12 발생). 즉, 단발이 아닌 반복 패턴이다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 cupixworks-api 가 다운로드 응답 자체를 끊었다 다운로드 URL이 internal API로 향함 API는 [302]만 로그 (16:21:00) — 본문 전송은 redirect 대상이 수행. 스택의 TLSWrap.onStreamRead도 TLS 스트림(외부 스토리지)을 가리킴 Rejected
H2 클라이언트 측 token 만료/auth 실패 cupixAuth.checkToken()이 download 시작 시점에 호출됨 에러 객체에 statusCode 없음, 401/403 아님. 메시지가 syscall-level read ECONNRESET Rejected
H3 Floorplan 88774가 유효하지 않음(데이터 문제) cupixworks-api302로 정상 redirect — 모델 존재 확인됨 Rejected
H4 외부/스토리지의 일시적 네트워크 끊김 + 클라이언트의 retry 부재로 인한 영구 실패 16:20:58 시작 → 16:24:24 끊김(약 3분 26초 idle), read ECONNRESET syscall, 같은 시간대 다른 모델에서도 ETIMEDOUT/undefined response 다발, 24시간 내 동일 service degraded incident 4회 Confirmed
H5 Sidecar/proxy(예: NLB, NAT, keepalive timeout) idle timeout으로 끊김 downloadOriginal 시작 후 ~3.5분 후에 정확히 끊김. 일반적으로 AWS NLB idle timeout 350s, NAT 350s 와 근접한 시간대 실제 idle 측정값은 로그만으로는 확정 불가 Inconclusive — needs verification

Fix Recommendation#

즉시 조치 (Critical)#

  • ImageCPObject.downloadFile에 네트워크 에러 retry 도입: 파일: packages/cupix-tesla-thumbnail-agent/src/model/common/image-cpobject.ts:53-91. 현재는 request.get'error' 이벤트가 발생하면 즉시 reject한다. ECONNRESET / ETIMEDOUT / EPIPE / socket hang up 등의 transient 네트워크 에러 코드를 식별하여 exponential backoff로 N회(예: 3회) 재시도한 뒤에만 reject되도록 변경한다. 단, 부분적으로 쓰여진 fileStream은 재시도 전에 반드시 cleanup(fileStream.destroy() + fs.unlink)해야 한다. 이미 downloadOriginal이 partial file size를 보고 skip하는 로직이 있으므로, 부분 파일 잔존 시 재실행에 영향을 줄 수 있다.

  • CupixAuth.retryable의 retry 조건 확장: 파일: packages/agents/api/src/authentication/cupix-auth.ts:59-87. 현재 e.statusCode > 500만 retry한다. statusCode가 없는 네트워크 에러(e.codeECONNRESET/ETIMEDOUT/EPIPE/ENOTFOUND 중 하나)도 retry 대상에 포함하도록 조건을 확장한다.

단기 개선 (1주 이내)#

  • ImageCPObject.uploadFile의 path 로깅 버그 수정: image-cpobject.ts:109,114에서 path 변수가 Node.js의 path 모듈을 가리키며 객체 dump가 로그에 그대로 찍히고 있다 ({ default: [Object], resolve: [Getter], normalize: [Getter], ... }). 인자명 pathfilePath로 사용하도록 수정 필요. 이는 RCA·디버깅 시 어떤 모델의 업로드가 실패했는지 식별 불가능하게 만든다.

  • SQS DLQ 정책 점검: BaseService::handlingMessageErrors가 한 번 실패 시 어떻게 동작하는지(DLQ 이동 vs visibility timeout 후 재처리) 확인하고, transient 에러는 메시지 visibility timeout 기반 자연 재시도가 가능하도록 보장한다(ApproximateReceiveCount가 1로 끝나는 점을 보면 재시도가 작동하지 않을 수 있음).

  • 다운로드 timeout 명시화: request.get 옵션에 timeout (예: 60s)을 명시. 현재 default(약 무한 또는 socket-level)는 idle 끊김 감지가 늦다.

장기 개선 (재발 방지)#

  • deprecated request 모듈 교체: request 라이브러리는 2020년부터 deprecated 상태. axios/undici/got 등 native retry/timeout/keepalive를 지원하는 라이브러리로 점진 교체.

  • Network-level retry layer: 모든 agent의 외부 HTTP 호출에 공통 retry/circuit-breaker 미들웨어를 두고, transient vs non-transient 에러를 분류한다.

  • NLB/NAT idle timeout vs HTTP keep-alive 정렬 확인: H5 관련. AWS NLB idle timeout(기본 350s)과 다운로드 stream의 평균 처리 시간이 가까우면 keep-alive 패킷 또는 chunk 흐름으로 idle 발생을 방지하는 옵션을 검토한다.

Monitoring#

writing-datadog-monitoring-queries skill 가이드에 따라 timeseries widget 호환 쿼리만 사용한다 (no | stats, no monitor-only suffix).

  • thumbnail-agent의 transient 네트워크 실패 추이(downloadFile/uploadFile 합산):
text
sum:logs.hits{service:cupixworks-any-thumbnail-agent,status:error,@message:*ECONNRESET*}.as_count()
text
sum:logs.hits{service:cupixworks-any-thumbnail-agent,status:error,@message:*ETIMEDOUT*}.as_count()
  • ImageCPObject 다운로드 실패 빈도:
text
sum:logs.hits{service:cupixworks-any-thumbnail-agent,status:error,@message:"ImageCPObject::downloadFile"}.as_count()
  • 실패 파일별 분포 확인용 (대시보드 group by):
text
sum:logs.hits{service:cupixworks-any-thumbnail-agent,status:error,@message:"ImageCPObject"} by {message}.as_count()

알림 제안: 5분 간격으로 같은 종류의 transient 실패가 ≥3건이면 Slack 알림. 단발 ECONNRESET 자체는 경고하지 않음.

Risk Assessment#

  • Risk level: medium — 단일 클러스터 발생량은 1건이지만 status board에서 24h 내 동일 패턴이 4회 반복되어, 누적 thumbnail 생성 실패가 사용자 경험(Floorplan/Asset 미리보기 누락)에 영향을 줌.
  • 예상 복잡도: standard — 잘 알려진 retry 패턴 추가. 다만 partial file cleanup, idempotency, retry budget 설정 등 신중한 구현 필요.