ES /docs

BaseService::handlingMessageErrors | Error and message object - {"error":{"message":"Request failed

RCA: BaseService::handlingMessageErrors | axios PUT to S3 me-central-1 → 400 Bad Request

Overview#

What Happened#

2026-07-14 03:53 KST 무렵 cupixworks-capture-postprocessor-agent 가 capture 692809 의 원본 프레임(/tmp/workspace/733798/videos/692809/original/692809_00226.jpg, 2712000 bytes) 을 me-central-1 리전 S3 presigned URL 로 PUT 하다가 AxiosError: Request failed with status code 400 을 받아 BaseService::handlingMessageErrors 가 전체 에러 페이로드를 로깅했다. axios 응답 인터셉터 상 __retryCount: 1 로 이미 한 차례 재시도가 발생한 상태였고, SQS 메시지는 ApproximateReceiveCount: 4 로 세 차례 재수신된 상태였다. 이 400 은 이후 03:57 wave (sibling cluster a4ab870e-e70d-4b40-9d76-f24cf3f9952d, 222건) 로 확대되기 직전의 첫 실패 시그널이다.

Quick Facts#

Field Value
exception.class AxiosError
exception.message Request failed with status code 400
exception.code ERR_BAD_REQUEST
top_frame packages/base/src/util/transfer.ts:57-64 (uploadFile) — 번들 상 dist/app.cjs:5730 uploadFile, BaseTransferManager2.doUploadFile
runtime Node.js, axios@1.16.1
env production, agent 호스트 us-west-2, target bucket cupixworks-source-b169da1a0187-mece1 (me-central-1)
sqs receive count ApproximateReceiveCount: 4 (동일 SQS message 4회 수신)
axios internal retry config.__retryCount: 1 (인터셉터가 1회 재시도)

Affected Teams#

Team / Domain Error Count Impact
gad (postprocessor-agent) 1 (handlingMessageErrors) + 6 me-central-1 400 (failTask, 03:53:31–03:53:56 KST) capture 692809 postprocess 첫 wave 실패 — 이후 sibling cluster a4ab870e 222건으로 확대
gpinet (capture 692809) 222 (sibling) 단일 capture 의 신규 pano 업로드 태스크 전체가 재시도 없이 실패, postprocessor job 중단

Timeline#

  1. 2026-07-14 03:52:32 KST — tesla-api 로부터 me-central-1 S3 presigned PUT URL 발급 (X-Amz-Date=20260713T185232Z, X-Amz-Expires=7200).
  2. 2026-07-14 03:53:31 KST — 692809_00226.jpg (2712000 bytes) PUT 재시도(__retryCount: 1) 도중 S3 me-central-1 이 400 Bad Request 응답. BaseService::handlingMessageErrors 로그 발생 (first_seen).
  3. 2026-07-14 03:53:33–03:53:56 KST — 동일 workspace/capture 원본 프레임 5건이 추가로 code: ERR_BAD_REQUEST, message: Request failed with status code 400failTask 종결.
  4. 2026-07-14 03:57:03–03:57:26 KST — SQS 메시지 재수신 후 UploadNewPanosTask.init() 단계에서 tesla-v1 SDK HttpError('HTTP request failed') 폭발 → 222건 failTask (sibling cluster a4ab870e-e70d-4b40-9d76-f24cf3f9952d).
  5. 2026-07-14 03:57:26 KST — 마지막 실패, 이후 다른 workspace 정상 처리.

Error Log#

Datadog Logs

text
BaseService::handlingMessageErrors | Error and message object - {"error":{"message":"Request failed with status code 400","name":"AxiosError","stack":"AxiosError: Request failed with status code 400
    at settle (/tmp/agent/dist/node_modules/.pnpm/axios@1.16.1/node_modules/axios/dist/node/axios.cjs:2069:12)
    ...
    at async uploadFile (/tmp/agent/dist/app.cjs:5730:7)
    at async BaseTransferManager2.doUploadFile (/tmp/agent/dist/app.cjs:5810:11)
    at async BaseTransferManager2.transferTask (/tmp/agent/dist/app.cjs:5896:15)"...
    ...
    "code":"ERR_BAD_REQUEST","status":400
  },
  "sqsMessage":{"MessageId":"a3fc639f-30eb-403c-a2fd-9602aae4df38","Attributes":{"ApproximateReceiveCount":"4"}}
}

응답 헤더 (원본 axios error 페이로드 내 res.rawHeaders):

text
Date: Mon, 13 Jul 26 18:53:31 GMT
Connection: close
Transfer-Encoding: chunked
x-amz-id-2: 4Mg7Yr9OFUTT/6speadjfZISVw3Up/NroB//KlY3Qp0x6FvVtyWzWdUHvV3rae9Q1psbtt+DBcunSbBuYpPwN1cJByheWrrZ
x-amz-request-id: 2388411C1F7583D4
Content-Type: application/xml
Server: AmazonS3
statusCode: 400

Impact#

  • Service: cupixworks-capture-postprocessor-agent
  • Team: gad
  • 발생 횟수: 1 (handlingMessageErrors) — 단, 같은 window 에 me-central-1 대상 failTask 6건, 이어지는 sibling cluster 222건 포함 시 실질 영향 규모는 capture 692809 전체
  • 최초 발생: 2026-07-14 03:53 KST
  • 최근 발생: 2026-07-14 03:53 KST

Root Cause Summary#

@agents/base 의 공유 uploadFile (packages/base/src/util/transfer.ts:57-64) 이 파일 스트림을 axios.put 호출 밖에서 딱 한 번 생성한 뒤 axios 인터셉터의 자동 재시도에 그대로 재사용한다. axios 인스턴스는 RETRYABLE_CODES (ECONNRESET, ETIMEDOUT, ECONNABORTED, EPIPE, EAI_AGAIN) 나 status >= 500 을 만나면 인터셉터가 config 를 그대로 재호출(return axios(config)) 하는데, 이때 config.data이미 소비된 fs.createReadStream 이다. 첫 시도의 네트워크 오류 후 재시도된 두 번째 요청은 body 를 사실상 다시 흘려보내지 못하고 Content-Length: 2712000 만 유지한 채 빈/부분 body 로 S3 me-central-1 에 도달하며, S3 는 IncompleteBody/SignatureDoesNotMatch 등에 해당하는 XML 을 담아 HTTP 400 Bad Request 로 응답한다. 결과적으로 (1) 정상 재시도 되어야 할 네트워크 오류가 (2) 스트림 재사용으로 인해 항상 4xx 로 귀결되고, (3) isRetryable 이 400 을 재시도 대상이 아니라고 판단해 failTask 로 즉시 종결된다. 뒤이어 SQS 메시지가 4회 재수신되며 다른 wave 에서는 tesla-v1 SDK 호출 자체가 실패해 sibling cluster 로 이어졌다.

Technical Analysis#

Code Path#

Entry point: SQS 메시지 처리 루프. BaseService.runByMessages() 가 실패하면 catch 로 handlingMessageErrors(error) 로 흘러가 axios error 를 통째로 로깅한다.

applications/agents/packages/base/src/base-service.ts:105-116typescript
} else {
    this._countWaitedToStopTask = 0;
    try {
        await this.runByMessages();
    } catch (error) {
        await this.handlingMessageErrors(error);
    }
    this.resetMessages();
    await CPUtils.sleep(500);
    await this.checkingQueue();
}
applications/agents/packages/base/src/base-service.ts:290-313typescript
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();
};

Upload 경로: BaseTransferManager.transferTask()doUploadFile()uploadFile().

applications/agents/packages/base/src/manager/transfer.manager.ts:63-68 (deploy/stage)typescript
doUploadFile = async (url: string, path: string, headers: any): Promise<void> => {
    logger.debug('TransferManager::uploadFile | begin path: %s, url: %s, size: %d', path, url, CPUtils.getFileSize(path));
    await uploadFile(url, path, headers);
    logger.debug('TransferManager::uploadFile | complete path: %s', path);
};

Failure pointuploadFile 가 스트림을 한 번만 만들어 axios 인터셉터가 그 스트림을 재시도 시 그대로 재사용한다:

applications/agents/packages/base/src/util/transfer.ts:57-64 (deploy/stage)typescript
export const uploadFile = async (url: string, path: string, headers: any): Promise<void> => {
    const fileStream = fs.createReadStream(path);
    const stats = fs.statSync(path);
    await axios.put(url, fileStream, {
        headers: { ...headers, 'Content-Length': stats.size },
        maxContentLength: Infinity,
        maxBodyLength: Infinity,
    });
};

axios 재시도 인터셉터 (같은 파일 상단, transfer.ts:12-34):

applications/agents/packages/base/src/util/transfer.ts:12-34 (deploy/stage)typescript
axios.interceptors.response.use(undefined, async (error: any) => {
    const config = error.config;
    if (!config) throw error;

    config.__retryCount = config.__retryCount || 0;

    const retryable = RETRYABLE_CODES.includes(error.code)
        || (error.response?.status && error.response.status >= 500);

    if (!retryable || config.__retryCount >= MAX_RETRIES) {
        throw error;
    }

    config.__retryCount += 1;
    ...
    return axios(config);  // config.data === 이미 소비된 fileStream
});

기대 동작: 네트워크 오류 시 재시도할 때마다 request body 를 새로 만들어 안전하게 재전송. 실제 동작: 첫 시도에서 소비된 read stream 이 재시도 config 에 그대로 남아 body 가 사실상 재전송 불가능한 상태로 S3 에 도달, S3 가 400 Bad Request 로 거부.

isRetryable 은 400 을 재시도 대상으로 간주하지 않으므로 즉시 failTask 종료:

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

이 문제는 TSLA-13233 (be11a0dd4, 2026-06-22 merge) 에서 forge-api.ts uploadStreamthumbnail.api.ts"매 시도마다 formData(fresh stream) 재구성" 으로 해결됐지만, @agents/base 의 공용 uploadFile 은 여전히 fileStream 을 한 번만 생성하는 원래 구현(TSLA-12622, 044344b50) 그대로 남아 있다.

Log Evidence#

Datadog 쿼리 (cluster 원본):

text
service:cupixworks-capture-postprocessor-agent status:error @environment:production "BaseService::handlingMessageErrors"

me-central-1 대상 실패 실 로그 6건 (03:53:31–03:53:56 KST, failTask 계열):

text
service:cupixworks-capture-postprocessor-agent "b169da1a0187-mece1" status:error
range: 2026-07-13T18:50:00Z ~ 2026-07-13T18:58:00Z
Found 6 logs

대표 failTask 로그:

text
2026-07-14 03:53:56 error
TransferManager::failTask | path: /tmp/workspace/733798/videos/692809/original/692809_00286.jpg, url: https://s3.me-central-1.amazonaws.com/cupixworks-source-b169da1a0187-mece1/resources/rg7jzs/mece1/v1?...&X-Amz-Date=20260713T185254Z&X-Amz-Expires=7200&X-Amz-Signature=..., count: 0/5, code: ERR_BAD_REQUEST, message: Request failed with status code 400

axios 에러 페이로드 핵심 필드 (원본 cluster body):

json
{
  "code": "ERR_BAD_REQUEST",
  "status": 400,
  "config": {
    "url": "https://s3.me-central-1.amazonaws.com/cupixworks-source-b169da1a0187-mece1/resources/xp10xs/mece1/v1?...&X-Amz-Date=20260713T185232Z&X-Amz-Expires=7200&X-Amz-Signature=...",
    "method": "put",
    "headers": {"Content-Type":"image/jpeg","Content-Length":"2712000"},
    "timeout": 60000,
    "__retryCount": 1
  },
  "res.statusCode": 400,
  "res.statusMessage": "Bad Request",
  "res.rawHeaders": ["...","x-amz-request-id","2388411C1F7583D4","Content-Type","application/xml","Server","AmazonS3"]
}

SQS 메시지 재수신 상태 (동일 페이로드 하단):

json
{
  "sqsMessage": {
    "MessageId": "a3fc639f-30eb-403c-a2fd-9602aae4df38",
    "Attributes": {"ApproximateReceiveCount": "4"}
  }
}

Presigned URL 은 20260713T185232Z 기준 X-Amz-Expires=7200 이므로 03:53:31 응답 시점(59초 경과) 에서는 만료 아님. Server: AmazonS3 헤더가 있으므로 응답 자체는 S3 origin 에서 나왔다. 응답 body(XML) 는 axios error dump 에는 실려 있지 않아 정확한 S3 error code(IncompleteBody, BadDigest, SignatureDoesNotMatch 등) 는 uncertain — needs verification via Watch(Kibana) debug 로그 또는 S3 access log.

Sibling wave (a4ab870e, 03:57:03–26 KST) 는 이 첫 wave 실패로 SQS 메시지가 재수신된 뒤 발생: tesla-v1 SDK HttpError('HTTP request failed')UploadNewPanosTask.init() 이 실패하며 url: undefined 시그니처의 failTask 로그가 222건 폭발.

Status board — 같은 서비스에서 동일 root_cause_type unknown 인시던트가 2026-07-10 18:10–18:35 UTC 에도 발생하여 해소된 이력. 재발 패턴(scope: svc:cupixworks-capture-postprocessor-agent::unknown).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 uploadFile 이 fileStream 을 한 번만 생성해 axios 인터셉터 재시도 시 이미 소비된 스트림이 재전송되어 S3 가 400 을 반환 config.__retryCount: 1 (인터셉터 재시도 1회 발생) + code: ERR_BAD_REQUEST + Server: AmazonS3 로부터 실제 400 응답, packages/base/src/util/transfer.ts:57-64 에서 stream 을 함수 스코프 상단 1회만 create, 인터셉터는 return axios(config) 로 동일 config.data 사용, TSLA-13233 이 같은 이슈를 forge-api/thumbnail-api 에서만 fresh stream 재구성으로 해결하며 base 는 미변경 Confirmed
H2 Presigned URL 이 만료됨 X-Amz-Expires=7200 (2시간) X-Amz-Date=20260713T185232Z 기준 응답 시각 03:53:31 UTC 로 59초 만 경과 → 만료 아님 Rejected
H3 me-central-1 S3 리전 전체 장애 Server: AmazonS3 헤더 존재 (S3 origin 이 응답 반환), 특정 리전 대상 오류 동일 시간대 다른 workspace 에는 영향 없음, 6건만 발생 후 즉시 회복, AWS Health status 확인 로그 없음 Rejected
H4 Signature/Content-Type 계산 오류 (앱 측 signing 버그) 400 발생 tesla-api 가 presigned URL 을 생성하며 agent 는 URL 만 사용, 정상 상황에서는 동일 코드가 성공하므로 signing 자체는 유효. 첫 시도가 (인터셉터 로그 상) 네트워크성 오류로 판단되어 재시도 진입한 흐름과 결합해야 400 발생 Rejected
H5 Content-Length 와 실제 body size 불일치로 IncompleteBody/BadDigest XML 반환 재시도된 요청에서 이미 소비된 스트림이 전송 → Content-Length 는 그대로 2712000 이지만 실제 전송 body 는 empty/partial. S3 표준적으로 이 경우 400 반환. res.Content-Type: application/xml 은 S3 XML error body 임을 시사 응답 body(XML) 원문이 axios error dump 에 실려 있지 않아 XML error code 문자열은 uncertain — Watch debug 로그로 검증 필요 Confirmed (probable — 정확한 S3 error code 는 needs verification)

Fix Recommendation#

즉시 조치 (Critical)#

  • applications/agents/packages/base/src/util/transfer.tsuploadFile 을 fresh stream re-creation 패턴으로 재작성한다. 방향: axios 인터셉터의 자동 재시도에 의존하지 말고, withRetry 형태의 명시적 loop 를 두어 각 시도마다 fs.createReadStream(path) 을 새로 생성한 뒤 axios.put(url, freshStream, {...}) 을 호출한다. TSLA-13233 이 packages/api/src/utils/transfer.ts (withRetry/uploadFile) 에 이미 동일 패턴을 만들어 두었으므로 그 구현을 base 로 병합/이식하는 것이 최소 변경 경로.
  • 병행 조치로, packages/base/src/util/transfer.ts:12-34 의 axios 응답 인터셉터에서 error.config?.dataReadable 스트림인 경우 자동 재시도를 스킵하도록 가드한다. 스트림을 재사용하는 재시도는 근본적으로 불가하므로 사일런트 실패보다 즉시 throw 가 안전하다.
  • isRetryable (transfer.ts:66-72) 이 error.code === 'ERR_BAD_REQUEST' 이고 error.config?.__retryCount > 0 이며 요청이 stream body 인 경우, "인터셉터 재시도로 인한 스트림 재사용 400" 을 판별해 상위 재시도(failTask 대신 upload 태스크 자체 재시도) 로 넘길지 여부를 결정한다 — 다만 이 경우 태스크 레벨 재시도도 fresh stream 을 보장해야 하므로 즉시 조치 첫 항목이 선행되어야 함.

단기 개선 (1주 이내)#

  • packages/api/src/utils/transfer.spec.ts 에 이미 있는 "retry 당 stream 재생성 검증" 스펙 셋을 base packages/base/src/util/transfer.spec.ts 로 확장하여 base uploadFile 도 동일 계약을 가짐을 회귀 방지한다.
  • Datadog log-based metric 을 추가해 handlingMessageErrors 페이로드 내 __retryCount > 0 발생 빈도를 별도로 추적한다. 정상적으로 재시도 후 성공한 케이스와, 재시도 후 400/기타 실패로 종결된 케이스를 분리 추적해야 이 클래스의 회귀를 조기 감지할 수 있다.
  • failTask 로그에 error.response?.data (S3 XML error body) 를 최소 첫 512 bytes 라도 포함시켜, 다음 발생 시 IncompleteBody/BadDigest/SignatureDoesNotMatch 등 정확한 S3 error code 를 곧바로 확인할 수 있게 한다.

장기 개선 (재발 방지)#

  • 모든 스트리밍 업로드(파일, formData, chunked) 는 "재시도 = fresh producer 재구성" 을 라이브러리 계약으로 강제하는 helper 를 도입한다. axios 인터셉터에서 자동 retry 를 허용하지 않고, 명시적 withRetry(fn, opts) 를 통해 매 시도마다 producer 를 호출하는 형태로 API 를 좁힌다. TSLA-13233 의 packages/api/src/utils/transfer.ts 가 이 방향의 시작점.
  • capture 별 postprocessor job 이 첫 wave 6건 실패 후 SQS 재수신으로 UploadNewPanosTask.init() 폭발까지 이어지는 경로에서, 초기 실패 시점에 job 을 즉시 DLQ/재시도 트리로 넘길지 여부(태스크 실패 예산) 를 정책화한다.

Monitoring#

Datadog 대시보드에 아래 timeseries 를 추가한다. 각 쿼리는 monitor-only 문법을 피하고 timeseries widget 에 그대로 붙일 수 있는 형태로 작성.

전체 cupixworks-capture-postprocessor-agent error rate:

text
sum:datadog.log.events{service:cupixworks-capture-postprocessor-agent,status:error}.as_count()

ERR_BAD_REQUEST (axios 400) 관련 failTask 카운트 (log-based metric 신규 생성 후):

text
sum:logs.transfer_manager.err_bad_request{service:cupixworks-capture-postprocessor-agent}.as_count()

BaseService::handlingMessageErrors 발생 카운트 (SQS 실패 진입 지점):

text
sum:datadog.log.events{service:cupixworks-capture-postprocessor-agent,status:error,@message:*handlingMessageErrors*}.as_count()

me-central-1 리전 S3 대상 4xx 카운트 (log-based metric logs.s3_upload_400_by_regionregion tag 로 생성 후):

text
sum:logs.s3_upload_400_by_region{service:cupixworks-capture-postprocessor-agent,region:me-central-1}.as_count()

임계값 알람은 ERR_BAD_REQUEST 관련 failTask 가 5분 내 3건 이상이면 warn, 10건 이상이면 critical 로 설정 (이번 인시던트 6건 / 25초 기준).

Risk Assessment#

  • Risk level: high — 발생 카운트는 1이지만 (a) axios 인터셉터 자동 재시도 + stream body 조합 이라는 라이브러리 계약 결함이 근본 원인이며, (b) 실제 트래픽 규모에서 첫 시도의 일시적 네트워크 오류가 발생할 때마다 재시도가 반드시 실패하도록 만들어져 있어 재발 가능성이 매우 높다. (c) 곧바로 sibling cluster 의 222건 폭발로 확대된 사례가 있어 blast radius 도 실측됨. (d) 2026-07-10 동일 scope 인시던트가 이미 있었음 (status board 재발 패턴).
  • 예상 복잡도: standard — packages/base/src/util/transfer.tsuploadFilewithRetry 패턴으로 재작성 + 인터셉터에서 stream body 자동 재시도 가드 추가 + spec 확장. TSLA-13233 참조 구현이 있어 설계 위험은 낮음.