ES /docs

BaseTransferManager isRetryable — HttpError 필드 누락으로 재시도 불가

RCA: TransferManager::failTask HTTP request failed (no retry)

Overview#

3d-reconstruction agent가 byuk tenant의 octree pointcloud를 s3.eu-west-2.amazonaws.com으로 업로드하다 첫 시도에서 실패했고, retry 없이 즉시 failTask로 넘어갔다. 에러 객체에 code와 HTTP status가 모두 없어 isRetryable 판정이 false로 나오면서 5회 재시도 예산이 하나도 사용되지 않았다.

What Happened#

2026-07-23 02:52 KST에 cupix-capture-3d-reconstruction-agent가 byuk tenant의 byuk.44068_131881_octree.bin을 pre-signed AWS S3 URL(eu-west-2)로 PUT하다 실패했다. axios가 던진 error에 code: undefined, statusCode: undefined, message: "HTTP request failed"만 담겨 있어 재시도 정책이 이 실패를 재시도 불가로 분류하고 failTask를 즉시 호출했다. 1건 단발성 이벤트지만, 재시도 안전망이 우회된 사례라 관측성과 재시도 조건을 검토할 필요가 있다.

Quick Facts#

Field Value
exception.class TransferManager::failTask (log-only, no thrown class)
exception.message HTTP request failed
top_frame applications/agents/packages/base/src/manager/transfer.manager.ts:166-170
deploy 74c91b011 (TSLA-13701 TransferManager::failTask improve HttpError observability)
env production, eu-central-1 (agent), eu-west-2 (S3 target)

Affected Teams#

Team / Domain Error Count Impact
byuk (3d-reconstruction) 1 octree pointcloud 업로드 1건 실패, capture 44068 후속 파이프라인 중단 가능성

Timeline#

  1. 2026-07-23 02:24 KSTThreeDReconstruction::runThreeDReconstruction가 byuk / production / eu-central-1에서 시작됨.
  2. 2026-07-23 02:29 KST — 동일 도메인에서 재개 로그 (2번째 info 이벤트).
  3. 2026-07-23 02:52:49 KST — 첫 upload 시도가 즉시 실패, TransferManager::failTask 발화, count: 0/5로 종료.

Error Log#

Datadog Logs

text
TransferManager::failTask | path: /tmp/workspace/byuk.44068_result/byuk.44068_131881_octree.bin, url: https://s3.eu-west-2.amazonaws.com/cupixworks-hosting-4c317feb9604-euwe2/562df1e3fb63e832/octree/euwe2/pointclouds/135132?x-amz-acl=bucket-owner-full-control&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQBGWD5FH4B3TIZO5%2F20260722%2Feu-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260722T175141Z&X-Amz-Expires=7200&X-Amz-SignedHeaders=host&X-Amz-Signature=1dc6b31e25565bb8c74b4dd78b5f0a31370868716b4b17581fbeb04f3c80f980, count: 0/5, code: undefined, message: HTTP request failed

Impact#

  • Service: cupixworks-capture-3dreconstruction-instance
  • Team: byuk
  • 발생 횟수: 1
  • 최초 발생: 2026-07-23 02:52 KST
  • 최근 발생: 2026-07-23 02:52 KST

Root Cause Summary#

Upload 시도 중 axios가 던진 error 객체에 coderesponse.status가 둘 다 비어 있어, isRetryable이 어떤 조건에도 걸리지 않고 false를 반환했다. retryTaskcanRetry false 분기 → failTask 경로가 첫 시도(retryCount=0)에서 즉시 실행되어 재시도 5회 예산이 소비되지 않았다. 에러 message "HTTP request failed"는 axios 기본 message 문자열이 아니며, timeout이나 network error도 아닌 특정 low-level 실패(예: aborted request, TLS handshake 실패, keep-alive socket 종료)에서 error.code가 유실된 채 전달됐을 가능성이 크다. 즉, 진짜 문제는 두 가지가 겹쳐 있다: (1) axios error가 code/statusCode 없이 도달했고, (2) 그런 error를 non-retryable로 취급하는 정책이 안전하지 않다.

Technical Analysis#

Code Path#

Entry point: TransferManager::transferTask (base 클래스)의 catch 블록이 retryTask 호출.

applications/agents/packages/base/src/manager/transfer.manager.ts:195-217typescript
private transferTask = async (task: ITransferTask): Promise<void> => {
    try {
        await task.init();
        // ...
        if (task.action === TaskAction.Upload) {
            await this.doUploadFile(task.url, task.target, task.Headers);
        }
        await this.onAfterTransfer(task);
        this.completeTask(task);
    } catch (error) {
        this.retryTask(task, error);
    }
};

Retry gate: retryTaskcanRetry를 호출하고, 3d-reconstruction agent는 isRetryable(error) && task.retry()로 오버라이드한다.

applications/agents/packages/cupix-capture-3d-reconstruction-agent/src/manager/transfer.manager.ts:39-41typescript
protected canRetry(task: ITransferTask, error: any): boolean {
    return isRetryable(error) && (task as BaseTask).retry();
}

Failure point: isRetryablecode도 status도 없는 error에 대해 false 반환.

applications/agents/packages/base/src/util/transfer.ts:66-72typescript
export 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;
};

RETRYABLE_CODES = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EPIPE', 'EAI_AGAIN']. error가 이 목록에 없고 HTTP status도 없으면 false. Fail 로그는 다음 지점에서 발화한다.

applications/agents/packages/base/src/manager/transfer.manager.ts:160-170typescript
private failTask = (task: ITransferTask, error: any): void => {
    this._running = false;
    this._stop = true;
    // ...
    this._failed.push(task);
    this.onTaskFailed(task, error);
    logger.error('TransferManager::failTask | path: %s, url: %s, count: %d/%d, code: %s, statusCode: %s, message: %s, body: %s',
        task.target, task.url, task.retryCount, MaxRetries,
        error?.code, error?.statusCode ?? error?.response?.statusCode, error?.message,
        summarizeResponseBody(error?.response?.body));
};

기대 동작: 일시적 네트워크 실패는 5회까지 재시도(MaxRetries=5). 실제 동작: retryCount=0에서 즉시 fail. code, statusCode, body 모두 undefined로 로깅됐고, 상위 계층이 onTaskFailedpostErrorProcess로 실패 상태를 전파했다.

Log Evidence#

Datadog query (재현용):

text
service:cupixworks-capture-3dreconstruction-instance status:error "TransferManager::failTask"

동일 시간대 byuk 관련 컨텍스트:

text
2026-07-23 02:24:00 INFO  ThreeDReconstruction::runThreeDReconstruction environments | domain: byuk, envName: production, launchMode: CUPIXWORKS, region: eu-central-1, userEmail: undefined
2026-07-23 02:29:34 INFO  ThreeDReconstruction::runThreeDReconstruction environments | domain: byuk, envName: production, launchMode: CUPIXWORKS, region: eu-central-1, userEmail: undefined
2026-07-23 02:52:49 ERROR TransferManager::failTask | path: /tmp/workspace/byuk.44068_result/byuk.44068_131881_octree.bin, url: https://s3.eu-west-2.amazonaws.com/..., count: 0/5, code: undefined, message: HTTP request failed

동일 서비스의 다른 실패 사례(비교용, 대부분 me-central2 MinIO에서 5/5 소진 후 실패):

text
2026-07-23 03:05:18 ERROR TransferManager::failTask | ..., count: 5/5, code: ERR_BAD_RESPONSE, message: Request failed with status code 503
2026-07-23 02:35:26 ERROR TransferManager::failTask | ..., count: 5/5, code: ERR_BAD_RESPONSE, message: Request failed with status code 503

이 클러스터만 유일하게 count: 0/5, code: undefined로 다른 실패 패턴과 구분된다. eu-west-2(진짜 AWS S3)로 향한 upload가 재시도 정책 밖에서 종료된 유일한 케이스다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 axios error가 code/statusCode 없이 전달되어 isRetryable가 false 반환, 첫 시도에 즉시 fail count: 0/5, code: undefined, isRetryable 로직이 이 두 조건 없으면 false 반환 (util/transfer.ts:66-72) Confirmed
H2 S3(eu-west-2)가 5xx로 응답해서 fail 다른 로그들은 5/5 소진 후 Request failed with status code 503으로 실패 이 로그는 code: undefined, statusCode 없음, count 0/5 — HTTP 응답 자체를 받지 못한 상태 Rejected
H3 Pre-signed URL 만료로 인한 auth 실패 (403) URL은 X-Amz-Date=20260722T175141Z, X-Amz-Expires=7200 → 유효기한 2026-07-22T19:51Z까지, 실제 실패 17:52Z → 유효기간 내 statusCode 있어야 하는데 undefined Rejected
H4 axios interceptor가 retry 중이었고 최종 실패에서 code 유실 interceptor는 RETRYABLE_CODES 또는 status>=500에서만 3회 재시도 (util/transfer.ts:12-33), 그 외 error는 그대로 throw. 재시도 없이 원본 error가 throw됐을 가능성이 있음 로그에서 interceptor의 transfer::retry info 로그가 없음 → interceptor가 재시도 시도하지 않음 (즉, 처음부터 retryable 아님으로 판정) Rejected
H5 file stream 실패 (fs.createReadStream/ENOENT 등) uploadFilefs.createReadStream(path) 사용 (util/transfer.ts:57-64). fs error라면 error.code는 ENOENT처럼 채워짐 code: undefined라 fs error 형태 아님 Rejected
H6 TCP/TLS handshake 실패로 axios가 code 없는 error를 던짐 (예: keep-alive socket close, proxy 중단) message: HTTP request failed는 axios 기본 message가 아니라 wrapping된 문자열. code: undefined, statusCode: undefined, response: undefined 조합은 응답 전 단계 실패와 일치. eu-west-2(멀리 있는 리전)로의 국경 간 트래픽에서 드물게 발생 가능 단발 이벤트라 재현 어려움, body 필드가 새로 추가됐지만 이 로그는 구 포맷(body 필드 없음)이라 서비스가 새 로거 배포 전일 가능성 있음 — 즉, 아직 커버되지 않은 실패 케이스 Inconclusive (H1이 confirmed root cause이고 H6은 그 원인 시나리오 후보)

Fix Recommendation#

즉시 조치 (Critical)#

  • 파일: applications/agents/packages/base/src/util/transfer.ts:66-72
  • 접근: isRetryable를 conservative-retry 방향으로 확장한다. HTTP 응답 자체를 받지 못한 error (즉 error.response가 없고 error.code도 없는 axios error)는 network-level 실패로 간주해 retry 대상으로 넣어야 한다. 5xx만 retry하는 현재 정책은 "응답 없음" 케이스를 사각지대로 남긴다.
  • 근거: 로그 evidence에서 이 케이스만 재시도 예산 0을 그대로 소비하고 실패했다. axios가 code 없는 error를 던지는 경로 (예: socket hang up이 code 없이 전달되는 버전, custom agent, DNS 실패 후 흡수된 error)를 방어해야 한다.
  • 방향 예시 (구현 코드 아님): error.response === undefined && !error.code 조건도 재시도 목록에 포함하되, 무한 루프 방지를 위해 retryCount < MaxRetries 게이트는 그대로 유지한다.

단기 개선 (1주 이내)#

  • axios 요청 config에 validateStatus, httpAgent/httpsAgent (keep-alive 및 socket timeout 명시), transitional 옵션을 검토해 error.code가 왜 유실되는지 재현 조건을 만들 것.
  • TSLA-13701이 실패 로그에 statusCodebody를 추가했지만, 이 클러스터 로그는 아직 이전 포맷을 사용 중 (body: 필드가 없음). 관측성 개선 commit이 실제로 3d-reconstruction agent 이미지에 배포됐는지 태그/버전으로 확인 필요.
  • axios interceptor의 transfer::retry info 로그가 이 실패에는 없다. 즉 interceptor 재시도조차 트리거되지 않았음을 명시적으로 기록하도록 axios.interceptors.response.use catch에서 non-retryable도 debug 로그로 남기면 향후 진단이 쉬워진다.

장기 개선 (재발 방지)#

  • 3d-reconstruction agent의 upload 실패 → capture 재처리 경로가 사람 개입 없이 회복되는지 리뷰. 현재 failTask는 컨테이너 전체를 stop시키고 (this._stop = true) 후속 upload도 처리하지 않는다. 부분 실패에 대한 격리 (partial fail + continue) 정책 도입을 검토.
  • eu-west-2 target으로의 pre-signed URL 요청을 별도 SLI로 측정해서, cross-region 업로드 실패율을 추적. 현재는 me-central-2 MinIO 5xx 노이즈에 묻혀 있음.

Monitoring#

  • 알림 추가: code: undefined && count: 0/5 조합이 시간당 N건 이상이면 warn (재시도 사각지대 감시).
  • Datadog widget용 timeseries queries:
text
service:cupixworks-capture-3dreconstruction-instance status:error "TransferManager::failTask"
text
service:cupixworks-capture-3dreconstruction-instance status:error "TransferManager::failTask" "count: 0/"
text
service:cupixworks-capture-3dreconstruction-instance status:error "TransferManager::failTask" "code: undefined"
  • 위 세 쿼리를 timeseries 위젯의 count 그래프로 시각화하고, 세 번째 쿼리(재시도 없이 실패한 케이스)가 기저선 대비 튀는 순간을 alert threshold로 잡는다.

Risk Assessment#

  • Risk level: low (단건, 단일 tenant, 단일 파일)
  • 예상 복잡도: standard (retry 조건 튜닝은 shared util/transfer.ts 변경이라 다른 agent에도 영향; 회귀 테스트 필요)