SYSTEM - Unhandled Rejection: Converting circular structure to JSON
RCA: SYSTEM - Unhandled Rejection: Converting circular structure to JSON
Overview#
What Happened#
cupixworks-capture-postprocessor-agent (production, us-west-2, sellen team) emitted a top-level unhandledRejection at 2026-07-24 09:34:40 KST. The rejection reason was a TypeError: Converting circular structure to JSON produced when a promise-chained code path called JSON.stringify() on an object holding a live TLSSocket. The process-level handler in src/app.ts logged the message and forced process.exit(1), ending the running agent container.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | TypeError |
| exception.message | Converting circular structure to JSON --> starting at object with constructor 'TLSSocket' | property '_httpMessage' -> object with constructor 'ClientRequest' --- property 'socket' closes the circle |
| top_frame | packages/cupix-capture-postprocessor-agent/src/app.ts:33-37 (process.on('unhandledRejection')) |
| runtime | Node.js (agent, TypeScript build) |
| env | production, region us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| sellen / capture-postprocessor-agent | 1 | 하나의 agent 컨테이너가 unhandled rejection 로 process.exit(1). SQS cupix-capture-postprocessor-agent-production 큐의 in-flight 메시지 처리 중단; ECS 재기동 후 다른 태스크가 이어 처리. 사용자 노출 영향은 확인되지 않음 (uncertain — needs verification). |
Timeline#
- 2026-07-24 09:33:00 KST — Datadog warn 로그
"socket hang up"(동일 서비스, 동일 인스턴스) — 외부 HTTPS 호출 하나가 소켓 레벨에서 실패. - 2026-07-24 09:33:01 KST —
BaseService::agentScaleOut | undefined queueAttributeswarn — SQSgetQueueAttributes콜백에서err를 받아resolve(undefined)함 (AWS SDK 콜백 실패, 이 시점 네트워크 불안정). - 2026-07-24 09:34:40 KST —
SYSTEM - Unhandled Rejection: Converting circular structure to JSON …— top-level 핸들러 캡처 후 프로세스 종료. - 2026-07-24 09:34:57 KST — 새 태스크가 job 1222405 를
BaseService::runByMessage로 재개 (다른 컨테이너 또는 재기동).
Error Log#
SYSTEM - Unhandled Rejection: Converting circular structure to JSON
--> starting at object with constructor 'TLSSocket'
| property '_httpMessage' -> object with constructor 'ClientRequest'
--- property 'socket' closes the circle
Impact#
- Service:
cupixworks-capture-postprocessor-agent - Team: sellen
- 발생 횟수: 1
- 최초 발생: 2026-07-24 09:34:40 KST
- 최근 발생: 2026-07-24 09:34:40 KST
Root Cause Summary#
에러 메시지 "Converting circular structure to JSON --> starting at object with constructor 'TLSSocket'" 은 V8 이 순환 구조를 만난 JSON.stringify() 호출에서 던지는 표준 TypeError 메시지이다. 즉 rejection reason 자체가 TypeError 이며, 어떤 promise 체인 안에서 코드가 JSON.stringify() 를 살아있는 TLSSocket 참조가 포함된 객체 (HTTP client error / AWS SDK error / axios error 등) 에 대해 호출하다가 던졌고, 이 rejection 을 아무도 .catch() 하지 않아 top-level 로 전파되었다. 이벤트 90초 전 동일 서비스에서 "socket hang up" warn 이 나온 것과 정합적 — 네트워크 실패가 만든 error 객체를 로깅/직렬화하는 경로에서 stringify 가 터졌다. packages/cupix-capture-postprocessor-agent/src/app.ts:33-37 의 process-level 핸들러가 이를 잡아 exitHandler({ exit: true }, 1) 로 프로세스를 종료시켰다.
Technical Analysis#
Code Path#
- Entry point (프로세스 시작):
packages/cupix-capture-postprocessor-agent/src/app.ts:39—new PostprocessorService().init() - Runtime loop:
packages/base/src/base-service.ts:86-116—checkingQueue()→runByMessages()(실패 시handlingMessageErrors) - 후보 실패 지점 (uncertain — needs verification):
packages/base/src/base-service.ts:311—logger.error('… - %s', JSON.stringify(errorAndMessage))에서errorAndMessage.error가 socket-holding error 인 경우.packages/cupix-capture-postprocessor-agent/src/manager/transfer.manager.ts:71,76,104,239—logger.error('… - %s', JSON.stringify(err))여러 곳.err가 axios/HTTP client error 인 경우 순환 참조 트리거.packages/cupix-capture-postprocessor-agent/src/postprocessor-service.ts:215,246,273— 각 upload* catch 블록의JSON.stringify(error).
- Top-level failure trap:
packages/cupix-capture-postprocessor-agent/src/app.ts:33-37
Top-level handler (실제 로그 출력을 만든 지점):
process.on('unhandledRejection', (reason: any) => {
console.error('SYSTEM - Unhandled Rejection:', reason);
logger.error('SYSTEM - Unhandled Rejection: %s', reason instanceof Error ? reason.message : JSON.stringify(reason));
exitHandler({ exit: true }, 1);
});
reason instanceof Error 가 true 였기 때문에 reason.message (= "Converting circular structure to JSON …") 가 그대로 출력되었다 — reason 자체가 V8 이 만든 TypeError 임을 확정한다.
handlingMessageErrors 후보 경로:
private handlingMessageErrors = async (error: any): Promise<void> => {
const errorAndMessage = {
error: error,
sqsMessage: {}
};
if (this.messageInProcess) {
errorAndMessage.sqsMessage = { MessageId: this.messageInProcess.MessageId, Attributes: this.messageInProcess.Attributes };
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));
resetLogMeta();
};
apiErrorObject 가 undefined 이고 checkReceiveCountToDeleteMessage() 도 false 인 경로에서는 errorAndMessage.error = error 원본 (예: TLSSocket 참조를 가진 HTTP 에러) 그대로 line 311 로 흘러들어간다. 이 JSON.stringify 가 던지면, handlingMessageErrors 는 async 이므로 반환 promise 가 rejected 상태가 된다. Caller checkingQueue (base-service.ts:110) 에서는 await this.handlingMessageErrors(error) 를 try/catch 없이 호출하고 있어 promise rejection 이 위쪽으로 전파되고, checkingQueue 자체가 checkingQueue() 를 .then chain 없이 재귀 호출/setTimeout 으로 예약하므로 최종적으로 unhandled 상태가 된다.
기대 동작: 어떤 원인의 예외라도 로거는 안전하게 직렬화해야 하고, handlingMessageErrors 내부에서 stringify 실패로 프로세스가 죽어서는 안 된다.
실제 동작: JSON.stringify 가 순환 참조에서 TypeError 를 던지고 → promise reject → top-level handler → process.exit(1).
Log Evidence#
Datadog 쿼리:
service:cupixworks-capture-postprocessor-agent status:error
service:cupixworks-capture-postprocessor-agent "Unhandled Rejection"
Query 범위 2026-07-24T00:33:00Z ~ 2026-07-24T00:36:00Z 결과 (KST 로 표시):
2026-07-24 09:34:40 ERROR SYSTEM - Unhandled Rejection: Converting circular structure to JSON
--> starting at object with constructor 'TLSSocket'
| property '_httpMessage' -> object with constructor 'ClientRequest'
--- property 'socket' closes the circle
2026-07-24 09:33:01 WARN BaseService::agentScaleOut | undefined queueAttributes
2026-07-24 09:33:00 WARN socket hang up
2026-07-24 09:33:00 INFO AwsQueueManager::deleteMessage | end - message id: cc23c485-7ab8-4370-83da-e501bdd56bb0
2026-07-24 09:33:00 INFO BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/3134
socket hang up warn 은 stack trace 없이 message 만 남아있어 어떤 호출자에서 왔는지는 (uncertain — needs verification). 이후 90초 동안 로그가 비어있고, 09:34:40 에 unhandled rejection 이 뜬 뒤 09:34:57 에 새 컨테이너가 job 1222405 를 이어받아 정상 처리를 재개.
지난 24시간 동일 service 의 반복 warn 패턴 (참고, 다른 클러스터에 해당):
BaseService::handlingMessageErrors | Error and message object - {"error":"undefined response","sqsMessage":{"MessageId":"e699d871-...","Attributes":{"ApproximateReceiveCount":"16"}}}
이 케이스들은 error 가 문자열 "undefined response" 이라 JSON.stringify 가 정상 완료 → 오늘 unhandled rejection 인스턴스는 error 가 순환 구조 객체였다는 점에서 구분된다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 어떤 promise 체인 안에서 JSON.stringify() 가 TLSSocket 참조를 가진 HTTP/AWS SDK error 객체를 직렬화하다 TypeError 를 던졌고, 이 rejection 이 .catch() 로 잡히지 않아 top-level 로 전파됐다. |
로그 메시지 문구가 V8 표준 TypeError 메시지와 정확히 일치 (--> starting at object with constructor 'TLSSocket' | property '_httpMessage' -> object with constructor 'ClientRequest'); rejection 발생 90초 전 동일 인스턴스에서 "socket hang up" warn (실패한 HTTPS 호출 존재); 코드베이스에 logger.*('... - %s', JSON.stringify(err)) 패턴이 다수 존재 (transfer.manager.ts:71,76,104,239, postprocessor-service.ts:215,246,273, base-service.ts:311). |
정확한 stringify 콜사이트를 stack trace 로 특정하지 못했다 (uncertain — needs verification). | Confirmed (원인 클래스), 정확 콜사이트는 Inconclusive |
| H2 | rejection reason 이 살아있는 TLSSocket 객체 자체이고, top-level handler 의 JSON.stringify(reason) (app.ts:35) 가 이를 직렬화하다 실패했다. |
app.ts 의 unhandledRejection 핸들러도 non-Error reason 에 대해 JSON.stringify 를 함. |
로그의 출력 문자열이 handler 의 %s 로 이미 렌더된 TypeError.message — 즉 reason instanceof Error 가 true 로 평가된 분기에서만 이 텍스트가 나온다. reason 이 TLSSocket 였다면 handler 안쪽 JSON.stringify(reason) 이 다시 던져 로그가 남지 않았을 것. |
Rejected |
| H3 | 외부 서비스 (AWS 등) 광범위 outage 로 인한 파생 에러. | 90초 전 socket hang up. |
status-board scope: svc:cupixworks-capture-postprocessor-agent::unknown, active: null. recent 에 2026-07-23 same-scope incident 가 있으나 이번 이벤트는 단일 발생. dep:* scope 활성 인시던트 없음. |
Rejected (외부 대규모 장애 아님, 단발적 network flake 가능성) |
Fix Recommendation#
즉시 조치 (Critical)#
packages/cupix-capture-postprocessor-agent/src/app.ts:33-37process-levelunhandledRejection핸들러가 있으므로 프로세스는 안전하게 죽는다 — 즉시 조치의 목표는 "rejection 자체가 top 까지 올라오지 않도록" 이다.- Repo-wide 로
JSON.stringify(err)/JSON.stringify(error)패턴을 감사하고, error 객체 로깅 시getCircularReplacer또는util.inspect(err, { depth: 3, breakLength: Infinity })로 대체하거나, cplogger 가 이미 Error 를 안전 렌더한다면%s+JSON.stringify를 제거하고 err 자체를 splat 으로 넘기는 방식으로 통일. 확인 위치 (근거는 위 grep):packages/base/src/base-service.ts:311packages/cupix-capture-postprocessor-agent/src/manager/transfer.manager.ts:71, 76, 104, 239packages/cupix-capture-postprocessor-agent/src/postprocessor-service.ts:215, 246, 273packages/cupix-capture-postprocessor-agent/src/manager/file-system.manager.ts:98, 442packages/cupix-capture-postprocessor-agent/src/model/cppano.ts:378, 413
- 팀 conventions 확인 필요:
@agents/utils의logger/CPLogger가 Error 를 native 처리하는지 (applications/agents/**다른 fix 사례에서%s를 떼는 패턴 존재) — 확인 후 일괄 규칙화.
단기 개선 (1주 이내)#
BaseService::handlingMessageErrors(packages/base/src/base-service.ts:290-313) 내부의 line 311 을 try/catch 로 감싸거나,errorAndMessage.error를 미리 안전 문자열로 정규화 (예:error instanceof Error ? { name, message, stack } : String(error)). error handler 자체가 죽어서 process 를 다운시키는 것을 방지.checkingQueue(base-service.ts:107-115) 의await this.handlingMessageErrors(error)를try/catch로 감싸 handler 실패가 상위로 새지 않도록 격리.- AWS SDK v2 는 콜백-스타일이라 error 객체 안에 request/response/socket 이 붙는 경우가 있다.
AwsQueueManager(packages/base/src/manager/aws-queue.manager.ts:62, 127) 에서logger.warn('...', err)로 넘기는 대신, cplogger 사용 규칙에 맞춰 err.message / err.code 만 추출해 넘기는 방식이 안전.
장기 개선 (재발 방지)#
- 공용 helper (
safeStringify(obj)) 를@agents/utils에 추가하고 codebase 전반의JSON.stringify(err)를 이걸로 대체.WeakSet기반 replacer 로 순환 참조 방어. - ESLint 룰 or repo 검사 스크립트로 "logger.* 두번째 인자로
JSON.stringify(err)호출 금지" 를 lint. sibling 파일이 이미 스무 개 넘게 이 패턴을 쓰고 있으므로 반복 재발 가능성 큼. - 모든 agent 의
unhandledRejection핸들러 (동일 패턴이 서비스별로 repeat, 위 grep 결과 25+ 개) 를 공용packages/base로 승격.
Monitoring#
- Unhandled rejection 발생 시 즉시 알림. Datadog 쿼리:
service:cupixworks-*-agent status:error "SYSTEM - Unhandled Rejection"
- Circular structure JSON 실패만 트래킹:
service:cupixworks-*-agent status:error "Converting circular structure to JSON"
- 특정 서비스의 unhandled rejection 발생 카운트 timeseries:
service:cupixworks-capture-postprocessor-agent status:error @message:"Unhandled Rejection"
- 선행 지표로
socket hang upwarn 급증 감시:
service:cupixworks-capture-postprocessor-agent status:warn "socket hang up"
Risk Assessment#
- Risk level: low (단일 발생, 프로세스는 top-level handler 로 안전 종료, ECS 가 재시작; SQS 메시지는 visibility timeout 후 재전달)
- 예상 복잡도: standard (콜사이트 특정 후
safeStringify도입 +handlingMessageErrors방어 코드)