diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1ed4437..116d9ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,22 @@ jobs: steps: - uses: actions/checkout@v4 + # 추출 실패 code 계약(정본은 infra)을 같은 러너에 둔다 — ExtractionErrorCatalogTest 가 + # shared-infra/contracts/extraction-error-codes.yaml 을 읽어 translate 매핑·메트릭 reason 과 대조한다. + # 경로가 shared-infra 인 이유: infra 의 install.sh 도 로컬에 같은 경로로 설치해 로컬과 CI 가 같은 파일을 본다. + # 체크아웃이 없으면 그 테스트가 실패한다(skip 하면 계약 강제가 조용히 사라지므로 의도된 동작). + # 옵션은 extractor 쪽 같은 스텝과 일치시킨다(extractor#32) — 같은 목적으로 같은 repo 를 받는 두 소비자가 + # 갈리면 한쪽만 손보게 된다. sparse-checkout 은 필요한 디렉터리만, persist-credentials=false 는 러너에 + # 자격증명을 남기지 않게, ref 는 어느 브랜치가 정본인지 명시한다. + - name: Checkout extraction contract + uses: actions/checkout@v4 + with: + repository: TeamPiKi/infra + ref: main + path: shared-infra + sparse-checkout: contracts + persist-credentials: false + - uses: actions/setup-java@v4 with: java-version: '25' diff --git a/.gitignore b/.gitignore index 314a47f2..a62f615b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ .claude/commands/session-close.md .claude/rules/testing-principles.md +# infra(TeamPiKi/infra) 체크아웃 자리 — 계약 카탈로그(contracts/*.yaml)를 로컬은 install.sh 가, CI 는 ci.yml 의 +# 체크아웃 스텝이 여기 둔다. 정본은 infra 이므로 이 repo 는 사본을 추적하지 않는다. +shared-infra/ + # IDE - 개인 설정 제외 .idea/* !.idea/codeStyles/ diff --git a/build.gradle.kts b/build.gradle.kts index 859c15cd..1d317eb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -152,6 +152,16 @@ tasks.withType { useJUnitPlatform() // JDK 21+부터 동적 에이전트 로딩이 제한됨. Mockito(ByteBuddy)가 런타임에 에이전트를 붙이므로 명시적 허용 필요. jvmArgs("-XX:+EnableDynamicAgentLoading") + + // 추출 실패 code 계약 카탈로그(ExtractionErrorCatalogTest 가 읽는다)는 소스 트리 밖의 설치본이라 + // Gradle 이 입력으로 보지 못한다. 명시하지 않으면 카탈로그만 바뀐 상황에서 test 가 UP-TO-DATE 로 건너뛰어 + // 어긋난 채 초록불이 된다(실측). optional 인 이유: 미설치 환경에서 파일 부재로 task 구성이 깨지지 않게 — + // 부재 자체는 그 테스트가 실패로 판정한다. + inputs + .files(layout.projectDirectory.file("shared-infra/contracts/extraction-error-codes.yaml")) + .withPropertyName("extractionErrorCatalog") + .withPathSensitivity(PathSensitivity.RELATIVE) + .optional() } // Spring Boot 플러그인은 실행 가능한 boot jar 와 라이브러리용 plain jar 를 함께 만든다. 이 앱은 라이브러리로 diff --git a/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt b/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt index 33e1bcfc..930b6afe 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt @@ -6,7 +6,6 @@ import com.depromeet.piki.common.exception.HttpMappable import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.service.ImageSnapshotExtractor import com.depromeet.piki.product.service.ProductSnapshot -import com.depromeet.piki.product.service.ProductSnapshotException import io.micrometer.core.instrument.MeterRegistry import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry @@ -153,7 +152,7 @@ class AsyncImageParsingWorker( } // 확정 실패 — 상품 아님·추출값 신뢰 불가 등. 다시 해도 결과가 같으니 즉시 FAILED + raw 회수. // 단 전이가 실제로 적용됐을 때만이다 — 좀비 폐기·전이 실패면 결과를 세지도, raw 를 지우지도 않는다. - val reason = reasonOf(e) + val reason = ItemParsingMetrics.reasonOf(e) if (!markFailedQuietly(itemId, snapshotId, attempt)) return log.info( "item.parse.result item={} type=image result={} reason={} latency={}ms", @@ -167,12 +166,6 @@ class AsyncImageParsingWorker( deleteRawQuietly(imageKey) } - private fun reasonOf(e: Throwable): String = - when (e) { - is ProductSnapshotException -> ItemParsingMetrics.REASON_NOT_PRODUCT - else -> ItemParsingMetrics.REASON_PERMANENT_ERROR - } - // raw 원본 회수는 best-effort — 삭제 실패가 파싱 결과(이미 READY/FAILED 확정)를 되돌리지 않는다. // 회수 못 한 raw 와 recover 상한 FAILED·유실분은 items/raw/ S3 lifecycle 이 백업으로 만료한다. private fun deleteRawQuietly(imageKey: String) { diff --git a/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt b/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt index 56a51022..b8aa58b2 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt @@ -6,7 +6,6 @@ import com.depromeet.piki.common.exception.HttpMappable import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductLinkExtractor import com.depromeet.piki.product.service.ProductSnapshot -import com.depromeet.piki.product.service.ProductSnapshotException import io.micrometer.core.instrument.MeterRegistry import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry @@ -153,9 +152,9 @@ class AsyncItemParsingWorker( releaseQuietly(itemId, snapshotId, attempt) return } - // 확정 실패 — 상품 아님·추출값 신뢰 불가·호스트 차단·4xx 접근 불가 등. 같은 URL 을 다시 파싱해도 결과가 - // 같으므로 즉시 FAILED 로 종결한다(사용자에게 빨리 알림). 클라이언트 입력 계약 위반이라 서버 입장에선 정상 동작(info). - val reason = reasonOf(e) + // 확정 실패 — 상품 아님·못 읽음·값 불신·대상 차단 등. 같은 URL 을 다시 파싱해도 결과가 같으므로 + // 즉시 FAILED 로 종결한다(사용자에게 빨리 알림). 사유는 예외가 들고 온 bucket 에서 파생한다. + val reason = ItemParsingMetrics.reasonOf(e) // 전이가 실제로 적용됐을 때만 결과를 원장에 남긴다 — 좀비 폐기·전이 실패면 이 워커의 결과는 반영되지 않았다. if (!markFailedQuietly(itemId, snapshotId, attempt)) return log.info( @@ -171,14 +170,6 @@ class AsyncItemParsingWorker( ItemParsingMetrics.record(meterRegistry, ItemParsingMetrics.RESULT_FAILED, reason) } - // 확정 실패의 메트릭 reason. 상품 아님·추출값 신뢰 불가(ProductSnapshotException)는 not_product 로 따로 센다 - // (대시보드에서 "상품 아님"을 구분). 그 외 재시도 무의미 오류(원격 422 확정 실패 — 호스트 차단·4xx 등의 원격 번역)는 permanent_error. - private fun reasonOf(e: Throwable): String = - when (e) { - is ProductSnapshotException -> ItemParsingMetrics.REASON_NOT_PRODUCT - else -> ItemParsingMetrics.REASON_PERMANENT_ERROR - } - // 소유권 반납 — 실패해도 흡수한다. 반납은 **지연 단축 장치이지 정합성 장치가 아니다**: 반납이 안 되면 그 행은 // 예전처럼 stale 회수(마지막 박동 + 60s)가 늦게라도 잡고, 그래도 안 되면 마감이 끊는다. 그래서 여기서 던지지 않는다. // 레이스로 이미 마감 종결됐거나 소유권을 잃었으면 서비스가 false 를 주거나 entity check 가 던지고, 둘 다 정상 상황이다. diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt index 7f7704e6..098222d5 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt @@ -1,5 +1,8 @@ package com.depromeet.piki.item.service +import com.depromeet.piki.common.exception.HttpMappable +import com.depromeet.piki.product.service.ExtractionFailureBucket +import com.depromeet.piki.product.service.ExtractionFailureCode import io.micrometer.core.instrument.MeterRegistry // 파싱 단건의 종결 결과(READY/FAILED)를 result·reason 라벨로 센다 — 추출 실패가 트래픽에서 얼마나·왜 나는지 관측한다(#506). @@ -17,12 +20,24 @@ object ItemParsingMetrics { // 성공. const val REASON_NONE = "none" - // 워커 확정 실패 — 상품 페이지 아님·추출값 신뢰 불가(ProductSnapshotException). 클라이언트 입력 계약 위반. + // 워커 확정 실패 5종 — "이 숫자가 늘면 누가 무엇을 하는가"로 나눈다(#936). 값은 계약 카탈로그 + // (shared-infra/contracts/extraction-error-codes.yaml)의 bucket 과 같은 문자열이어야 한다: 원격 code 를 + // 우리 예외로 번역할 때 붙는 bucket 이 그대로 이 라벨이 되고, ExtractionErrorCatalogTest 가 셋을 대조한다. + + // 사용자가 상품 아닌 걸 넣음. 정상 트래픽이라 할 일이 없다. const val REASON_NOT_PRODUCT = "not_product" - // 워커 확정 실패 — 재시도 무의미한 외부 오류(호스트 차단·4xx 접근 불가·redirect 비정상·Gemini 영구 오류). - // ErrorCategory 가 RETRYABLE 이 아니라 즉시 종결한 경우다. not_product(상품 아님)와 구분해 별도로 센다. - const val REASON_PERMANENT_ERROR = "permanent_error" + // 우리 구성으로 그 페이지를 못 읽음. 늘면 그 도메인의 허가 후보를 본다. + const val REASON_UNREADABLE = "unreadable" + + // 대상이 우리를 막음. 늘면 UNSUPPORTED 정책 후보를 본다. + const val REASON_BLOCKED = "blocked" + + // 추출은 됐는데 값을 믿을 수 없음. 늘면 모델·프롬프트·검증 규칙을 본다. + const val REASON_EXTRACT_QUALITY = "extract_quality" + + // 우리 버그·방어 발동, 또는 매핑되지 않은 원격 code. 늘면 코드를 조사한다. + const val REASON_INTERNAL_ERROR = "internal_error" // 추출은 됐으나 READY 전이가 값 검증에 막힘(이름 없음 등). const val REASON_READY_REJECTED = "ready_rejected" @@ -45,4 +60,22 @@ object ItemParsingMetrics { ) { registry.counter(METRIC, TAG_RESULT, result, TAG_REASON, reason).increment() } + + // 확정 실패 예외 → reason 라벨. 분류의 정본은 예외가 참조하는 ErrorCode 의 bucket 이고(ExtractionFailureCode), + // 여기서는 그 bucket 을 라벨 문자열로 옮기기만 한다 — 원격 code 가 늘어도 이 함수는 그대로다. + // when 이 exhaustive 라, bucket 이 추가되면 라벨을 정하지 않은 채로는 컴파일되지 않는다. + // + // bucket 을 못 얻는 경우(분류 밖 예외 — 코드 버그성 NPE·JVM Error, 또는 매핑되지 않은 원격 code)는 + // internal_error 다. 그 자리는 "우리가 이름을 아는 실패"가 아니라 조사 대상이라는 뜻이므로, 이름 없는 + // 실패를 다른 바구니에 섞지 않는다. 링크·이미지 두 워커가 같은 함수를 쓴다(같은 메트릭 모집단). + fun reasonOf(e: Throwable): String { + val bucket = ((e as? HttpMappable)?.errorCode as? ExtractionFailureCode)?.bucket ?: return REASON_INTERNAL_ERROR + return when (bucket) { + ExtractionFailureBucket.NOT_PRODUCT -> REASON_NOT_PRODUCT + ExtractionFailureBucket.UNREADABLE -> REASON_UNREADABLE + ExtractionFailureBucket.BLOCKED -> REASON_BLOCKED + ExtractionFailureBucket.EXTRACT_QUALITY -> REASON_EXTRACT_QUALITY + ExtractionFailureBucket.INTERNAL_ERROR -> REASON_INTERNAL_ERROR + } + } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt new file mode 100644 index 00000000..7950fbe2 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt @@ -0,0 +1,34 @@ +package com.depromeet.piki.product.service + +import com.depromeet.piki.common.exception.ErrorCode + +// 확정 실패(원격 422)를 **운영 액션 축**으로 나눈 분류(#936). "이 숫자가 늘면 누가 무엇을 하는가"가 기준이라, +// 실패의 기술적 원인이 아니라 대응이 같은 것끼리 묶인다. +// 계약 카탈로그(shared-infra/contracts/extraction-error-codes.yaml)의 bucket 과 1:1 이고, 파싱 메트릭의 +// reason 라벨(ItemParsingMetrics)도 여기서 파생한다 — 셋(카탈로그·예외·메트릭)이 어긋나면 +// ExtractionErrorCatalogTest 가 잡는다. +enum class ExtractionFailureBucket { + // 사용자가 상품 아닌 걸 넣었다. 정상 트래픽이라 할 일이 없다. + NOT_PRODUCT, + + // 우리 구성으로 그 페이지를 못 읽었다(빈 셸·추출할 본문 없음). 늘면 도메인 허가 후보를 본다. + UNREADABLE, + + // 대상이 우리를 막았다. 늘면 UNSUPPORTED 정책 후보를 본다. + BLOCKED, + + // 추출은 됐는데 값을 믿을 수 없다. 늘면 모델·프롬프트·검증 규칙을 본다. + EXTRACT_QUALITY, + + // 우리 버그이거나 우리 방어가 발동했다. 늘면 코드를 조사한다. + INTERNAL_ERROR, +} + +// 확정 실패 예외가 자기 bucket 을 스스로 밝히게 하는 ErrorCode. 예외 클래스가 늘어도 워커는 이 인터페이스만 +// 보므로 reason 파생 경로가 한 줄로 유지되고, bucket 은 code 정의 옆(single source)에 박힌다. +// +// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(카탈로그도 같은 모양). 일시 실패는 +// 소유권 반납으로 되살아나 종결 집계(reason)에 닿지 않고, 상한을 소진하면 recover 가 retry_exhausted 로 센다. +interface ExtractionFailureCode : ErrorCode { + val bucket: ExtractionFailureBucket? +} diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt index 3f300c2d..2b86cc43 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt @@ -1,25 +1,51 @@ package com.depromeet.piki.product.service import com.depromeet.piki.common.exception.ErrorCategory -import com.depromeet.piki.common.exception.ErrorCode // ProductSnapshotException 의 code 배정표(에픽 #728). 번호는 append-only — 재배치·결번 침범 금지. // // ⚠️ 이 enum 은 ErrorCodeRegistry.all 에 **의도적으로 등록하지 않는다**(AnnouncementImageErrorCode 와 같은 선례). // 유일한 생성 경로인 ProductSnapshot.fromExtracted · RemoteExtractionContract.translate 는 비동기 파싱 // 워커(AsyncItemParsingWorker · AsyncImageParsingWorker)에서만 호출된다 — 워커가 예외를 잡아 item 을 FAILED 로 -// 전이시키고 메트릭 reason=not_product 로 집계할 뿐, GlobalExceptionHandler 를 거치지 않아 응답 code 로 나가지 않는다. +// 전이시키고 메트릭 reason(아래 bucket 에서 파생)으로 집계할 뿐, GlobalExceptionHandler 를 거치지 않아 응답 code 로 나가지 않는다. // 클라가 절대 받을 수 없는 code 를 공개 카탈로그에 넣으면 code→문구 매핑에 노이즈만 된다. // 여기서 code 를 부여하는 목적은 오직 예외 클래스 모양을 다른 도메인 예외와 통일(errorCode 참조)하는 것뿐이다. +// +// bucket 은 그 실패를 메트릭에서 무엇으로 셀지의 정본이다(#936) — 파싱 메트릭 reason 이 여기서 파생하므로, +// code 를 더할 때 bucket 도 함께 정한다. 원격 code → 여기의 어느 엔트리인지는 RemoteExtractionContract 가 정한다. enum class ProductSnapshotErrorCode( override val code: String, override val category: ErrorCategory, override val message: String, -) : ErrorCode { + override val bucket: ExtractionFailureBucket, +) : ExtractionFailureCode { // LLM 이 "상품 페이지가 아님"으로 판정. 링크 재등록·재시도 모두 무의미. - NOT_PRODUCT_PAGE("SNAPSHOT-001", ErrorCategory.INVALID_INPUT, "상품 페이지 링크만 등록할 수 있어요."), + NOT_PRODUCT_PAGE( + "SNAPSHOT-001", + ErrorCategory.INVALID_INPUT, + "상품 페이지 링크만 등록할 수 있어요.", + ExtractionFailureBucket.NOT_PRODUCT, + ), // 추출값이 유효 범위(가격 음수, 컬럼 길이 초과 등)를 벗어남. 추출 결과를 신뢰할 수 없다. // 구체 사유(어느 필드가 왜)는 message 에 담지 않고 로그로 남긴다. - UNTRUSTWORTHY_VALUE("SNAPSHOT-002", ErrorCategory.INVALID_INPUT, "상품 정보를 확인하지 못했어요. 직접 입력해 주세요."), + // bucket 이 not_product 가 아닌 이유(#936): "상품 아님"과 성격이 다르고 — 추출 자체는 됐는데 값을 못 믿는 것 — + // 대응도 모델·프롬프트·검증 규칙 쪽이라, 한 통에 두면 "상품 아님" 지표가 두 배로 부풀어 판단을 흐린다. + UNTRUSTWORTHY_VALUE( + "SNAPSHOT-002", + ErrorCategory.INVALID_INPUT, + "상품 정보를 확인하지 못했어요. 직접 입력해 주세요.", + ExtractionFailureBucket.EXTRACT_QUALITY, + ), + + // 우리가 그 페이지에서 읽어낼 본문을 얻지 못함(데이터 없는 CSR 셸·가시 텍스트 부재). 상품이 아닌 게 아니라 + // **지금 우리 구성으로 못 읽는** 것이라 도메인 허가 후보를 찾는 신호다. + // message 는 UNTRUSTWORTHY_VALUE 와 같다 — 사용자가 취할 행동(직접 입력)이 같고, 구분은 detail 이 아니라 + // bucket·로그가 진다(CLAUDE.md 메시지 톤). + NO_EXTRACTABLE_CONTENT( + "SNAPSHOT-003", + ErrorCategory.INVALID_INPUT, + "상품 정보를 확인하지 못했어요. 직접 입력해 주세요.", + ExtractionFailureBucket.UNREADABLE, + ), } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt index cc1b8307..2a9ce874 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt @@ -23,5 +23,9 @@ class ProductSnapshotException private constructor( // 추출값이 유효 범위(가격 음수, 컬럼 길이 초과 등)를 벗어남. 추출 결과를 신뢰할 수 없다. fun untrustworthyValue(): ProductSnapshotException = ProductSnapshotException(ProductSnapshotErrorCode.UNTRUSTWORTHY_VALUE) + + // 읽어낼 본문이 없어 추출이 성립하지 않음(데이터 없는 CSR 셸·가시 텍스트 부재). 상품이 아닌 것과 구분한다 — + // 같은 URL 을 지금 구성으로 다시 읽어도 결과가 같으므로 재시도는 무의미하다. + fun noExtractableContent(): ProductSnapshotException = ProductSnapshotException(ProductSnapshotErrorCode.NO_EXTRACTABLE_CONTENT) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt index ac09cc8a..d29c0187 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt @@ -1,7 +1,8 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.common.exception.ErrorCategory -import com.depromeet.piki.common.exception.ErrorCode +import com.depromeet.piki.product.service.ExtractionFailureBucket +import com.depromeet.piki.product.service.ExtractionFailureCode // ProductExtractorException 의 code 배정표(에픽 #728). 번호는 append-only — 재배치·결번 침범 금지. // @@ -9,9 +10,12 @@ import com.depromeet.piki.common.exception.ErrorCode // 생성 경로인 RemoteExtractionContract 의 유일한 소비자가 비동기 파싱 워커라, GlobalExceptionHandler 를 거치지 // 않고 워커의 재시도 판정(isRetryable)·item FAILED 전이로만 관측된다. 클라 대면 공개 카탈로그 대상이 아니다. // -// 두 사유가 같은 message 를 공유한다 — 원격이 왜 실패했는지는 사용자 관심사가 아니고, 구분은 category·로그가 진다. -// 재시도 여부만 갈린다: TRANSIENT_FAILURE 는 RETRYABLE(워커가 PROCESSING 유지 후 recover 재시도), -// PERMANENT_FAILURE 는 비 RETRYABLE(즉시 FAILED). +// 세 사유가 같은 message 를 공유한다 — 원격이 왜 실패했는지는 사용자 관심사가 아니고, 구분은 category·bucket·로그가 진다. +// 재시도 여부만 갈린다: TRANSIENT_FAILURE 는 RETRYABLE(워커가 소유권을 반납해 다음 tick 이 재실행), +// 나머지는 비 RETRYABLE(즉시 FAILED). +// +// bucket 은 확정 실패를 메트릭에서 무엇으로 셀지의 정본이다(#936). 일시(TRANSIENT_FAILURE)는 종결 집계에 +// 닿지 않으므로 bucket 이 없다(카탈로그의 transient code 와 같은 모양). // // status 교정: 종전엔 두 팩토리 모두 502 를 직접 들었으나, category 가 status 를 소유하게 되며 // PERMANENT_FAILURE(SERVER_ERROR)는 500 으로 파생된다(에픽 결정 2 의 OAuthException.misconfigured 502→500 과 동형). @@ -20,11 +24,28 @@ enum class ProductExtractorErrorCode( override val code: String, override val category: ErrorCategory, override val message: String, -) : ErrorCode { + override val bucket: ExtractionFailureBucket?, +) : ExtractionFailureCode { // 원격 호출이 일시적으로 실패(5xx·타임아웃·연결 실패·빈 응답·2xx 계약 위반). - TRANSIENT_FAILURE("EXTRACTOR-001", ErrorCategory.RETRYABLE, "상품 정보를 가져오지 못했어요."), + TRANSIENT_FAILURE("EXTRACTOR-001", ErrorCategory.RETRYABLE, "상품 정보를 가져오지 못했어요.", null), + + // 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 모르는 code 로 422 가 온 경우. + // tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미. + // 둘 다 "코드를 조사한다"가 대응이라 internal_error 로 센다: 전자는 우리 방어·버그이고, 후자는 매핑이 + // 뒤처졌다는 신호(카탈로그·translate 갱신)라 결국 코드 작업으로 귀결된다. + PERMANENT_FAILURE( + "EXTRACTOR-002", + ErrorCategory.SERVER_ERROR, + "상품 정보를 가져오지 못했어요.", + ExtractionFailureBucket.INTERNAL_ERROR, + ), - // 원격이 422(확정 실패)로 답했고, code 가 별도 의미 매핑 대상이 아닌 경우. tolerant reader — - // 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미. - PERMANENT_FAILURE("EXTRACTOR-002", ErrorCategory.SERVER_ERROR, "상품 정보를 가져오지 못했어요."), + // 대상이 우리를 막아 확정 실패. 우리 버그도 사용자 잘못도 아니라 따로 센다 — 늘면 그 도메인의 + // UNSUPPORTED 정책(백오피스) 후보가 된다. + BLOCKED_BY_TARGET( + "EXTRACTOR-003", + ErrorCategory.SERVER_ERROR, + "상품 정보를 가져오지 못했어요.", + ExtractionFailureBucket.BLOCKED, + ), } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt index 78c85c23..9dee23ca 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt @@ -8,8 +8,8 @@ import org.springframework.http.HttpStatus // 원격 추출기(extractor, 도메인 용어 product 의 ProductExtractor) 호출 실패. 워커(AsyncItemParsingWorker.isRetryable)의 // 재시도 판정이 category 만 보므로, extractor 계약의 3갈래 중 "일시(그 외 전부)"는 RETRYABLE 로, "확정(422)"는 SERVER_ERROR 로 번역한다. -// (NOT_PRODUCT_PAGE·UNTRUSTWORTHY_VALUE 는 이 예외가 아니라 기존 ProductSnapshotException 으로 되돌려 -// 워커 메트릭 reason=not_product 의 의미를 보존한다 — HttpProductLinkExtractor.translate 참고.) +// (확정 실패라도 "이 링크로는 상품 스냅샷을 만들 수 없다"는 사유 — 상품 아님·못 읽음·값 불신 — 는 이 예외가 +// 아니라 ProductSnapshotException 으로 되돌린다. 어느 code 가 어디로 가는지는 RemoteExtractionContract 참고.) // message·category·httpStatus 는 전부 errorCode 하나에서 파생한다(ProductExtractorErrorCode 가 single source). // errorCode 는 클래스 모양 통일 목적이며, 비동기 워커 전용이라 공개 카탈로그에 등록하지 않는다. class ProductExtractorException private constructor( @@ -25,8 +25,13 @@ class ProductExtractorException private constructor( fun transientFailure(cause: Throwable?): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.TRANSIENT_FAILURE, cause) - // 원격이 422(확정 실패)로 답했고, code 가 별도 의미 매핑 대상이 아닌 경우. tolerant reader — - // 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미이므로 비 RETRYABLE. + // 원격이 422(확정 실패)로 답했고, 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 + // 모르는 code 인 경우. tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). + // 재시도 무의미이므로 비 RETRYABLE. fun permanentFailure(): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.PERMANENT_FAILURE) + + // 원격이 422 로 답했고, 그 사유가 "대상이 우리를 막았다"인 경우(4xx 접근 거부·영구 upstream 거절). + // 재시도 무의미인 건 같고, 메트릭에서 blocked 로 따로 세어 정책(UNSUPPORTED) 판단의 입력이 된다. + fun blockedByTarget(): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.BLOCKED_BY_TARGET) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt index a40d989b..02da17ff 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt @@ -19,8 +19,35 @@ import org.springframework.web.client.RestClientResponseException internal object RemoteExtractionContract { private val log = LoggerFactory.getLogger(javaClass) - private const val CODE_NOT_PRODUCT_PAGE = "NOT_PRODUCT_PAGE" - private const val CODE_UNTRUSTWORTHY_VALUE = "UNTRUSTWORTHY_VALUE" + // 확정 실패(422) code 전수 → 우리 예외. 계약 카탈로그(shared-infra/contracts/extraction-error-codes.yaml)의 + // permanent code 를 빠짐없이 여기에 명시한다 — 표에 없는 code 는 아래 fallback 으로 떨어져 internal_error 로 + // 세지므로, 매핑 누락이 "우리가 이름을 아는 실패"인 척 묻히지 않는다. + // 표를 when 대신 값으로 둔 이유: 카탈로그와의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다. + // when 분기는 밖에서 열거할 수 없어 "누락이 else 로 조용히 흡수됐는지"를 기계가 가릴 수 없다. + // + // 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode), + // ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구). + internal val PERMANENT_TRANSLATIONS: Map BaseException> = + mapOf( + // 사용자가 상품 아닌 걸 넣었다 — 정상 트래픽. + "NOT_PRODUCT_PAGE" to { ProductSnapshotException.notProductPage() }, + "INVALID_URL" to { ProductSnapshotException.notProductPage() }, + // 우리 구성으로 못 읽었다 — 도메인 허가 후보 신호. + "EMPTY_SHELL" to { ProductSnapshotException.noExtractableContent() }, + "NO_EXTRACTABLE_CONTENT" to { ProductSnapshotException.noExtractableContent() }, + // 대상이 우리를 막았다 — UNSUPPORTED 정책 후보. + "FETCH_CLIENT_ERROR" to { ProductExtractorException.blockedByTarget() }, + "PERMANENT_UPSTREAM" to { ProductExtractorException.blockedByTarget() }, + // 추출은 됐는데 값을 믿을 수 없다 — 모델·프롬프트·검증 규칙 소관. + // IMAGE_UNSUPPORTED(이미지 경로 전용)도 "받은 결과를 상품 정보로 쓸 수 없다"는 같은 성격이다. + "UNTRUSTWORTHY_VALUE" to { ProductSnapshotException.untrustworthyValue() }, + "LLM_INVALID_RESPONSE" to { ProductSnapshotException.untrustworthyValue() }, + "IMAGE_UNSUPPORTED" to { ProductSnapshotException.untrustworthyValue() }, + // 우리 방어가 발동했다 — 정상 흐름이면 애초에 우리 경계(SSRF 가드·리다이렉트 제한)가 먼저 걸렀어야 한다. + "BLOCKED_HOST" to { ProductExtractorException.permanentFailure() }, + "TOO_MANY_REDIRECTS" to { ProductExtractorException.permanentFailure() }, + "MALFORMED_REDIRECT" to { ProductExtractorException.permanentFailure() }, + ) // 원격 추출 호출 한 건의 전부 — POST 부터 3갈래(2xx 매핑 / 422+code 확정 / 그 외 일시)가 이 함수 안에서 끝난다. // transport catch 까지 여기 두는 이유: 클라이언트별 복제가 남으면 계약이 진화할 때(타임아웃 구분·status 취급 등) @@ -77,10 +104,9 @@ internal object RemoteExtractionContract { } // 계약 3갈래 번역: 422 만 확정 실패, 그 외 status 는 전부 일시(fail-safe — recover 상한이 재시도를 바운드). - // NOT_PRODUCT_PAGE·UNTRUSTWORTHY_VALUE 는 기존 ProductSnapshotException 으로 되돌려, 워커 메트릭 - // (item.parsing reason=not_product)의 실패 의미가 유지되도록 한다. 그 외 code - // (이미지 전용 IMAGE_UNSUPPORTED 포함)는 모르는 code 와 같은 취급이다 — 전이 판정은 status 만으로 충분하고 - // code 는 관측용이라(계약 §1), 새 code 마다 매핑을 늘리지 않는다. + // **전이 판정은 여전히 status 만 본다** — code 는 그 확정 실패를 무엇이라 부르고 어떻게 셀지(bucket)만 가른다. + // 그래서 모르는 code 도 확정 실패라는 결론은 같고(tolerant reader, 계약 §1), 다만 internal_error 로 세어 + // "매핑이 뒤처졌다"가 지표에 드러난다. private fun translate( e: RestClientResponseException, target: String, @@ -95,12 +121,11 @@ internal object RemoteExtractionContract { runCatching { e.getResponseBodyAs(RemoteExtractionFailureResponse::class.java)?.code } .getOrNull() // code 는 관측·디버깅용(계약 §1) — 응답엔 노출되지 않고 로그로만 남긴다. 확정 실패는 계약상 정상 결과라 info. + // 원문 code 는 이 줄에만 남는다: 메트릭은 bucket 단위라(카디널리티) 개별 code 추적은 로그가 진다. log.info("remote extract permanent code={} {}", code, target) - return when (code) { - CODE_NOT_PRODUCT_PAGE -> ProductSnapshotException.notProductPage() - CODE_UNTRUSTWORTHY_VALUE -> ProductSnapshotException.untrustworthyValue() - else -> ProductExtractorException.permanentFailure() - } + // 표에 없는 code — 이 바이너리보다 새 extractor 가 사유를 늘렸거나 body 가 깨진 경우. 확정 실패인 건 같다. + val translation = PERMANENT_TRANSLATIONS[code] ?: return ProductExtractorException.permanentFailure() + return translation() } } @@ -125,7 +150,7 @@ internal data class RemoteExtractionResponse( // fromExtracted 를 반드시 경유한다 — https-only imageUrl(XSS 사다리 차단)·currency ISO 정규화·범위 검증은 // 모든 추출 경로가 공유하는 단일 진실 원천이고, 원격 계약이 정상 값을 보장하더라도 신뢰 경계(외부 서비스)를 // 넘어온 값은 우리 경계에서 다시 검증한다(다층 방어). 범위 위반은 - // untrustworthyValue(→ 워커 reason=not_product)로 떨어진다. + // untrustworthyValue(→ 워커 reason=extract_quality)로 떨어진다. // link 는 이미지 추출엔 원본 URL 이 없어 null 이다 — 이미지 경로의 계약(원본 URL 없음 — extractor 계약 §2). fun toProductSnapshot(link: ProductLink?): ProductSnapshot = ProductSnapshot.fromExtracted( diff --git a/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt b/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt index 3508e5ba..f5798838 100644 --- a/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt +++ b/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt @@ -3,6 +3,8 @@ package com.depromeet.piki.image.service.remote import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.common.storage.S3Properties import com.depromeet.piki.item.service.AsyncImageParsingWorker +import com.depromeet.piki.item.service.ItemParsingMetrics +import com.depromeet.piki.product.service.ProductSnapshotException import com.depromeet.piki.product.service.remote.ExtractionModelSettings import com.depromeet.piki.product.service.remote.ExtractionTarget import com.depromeet.piki.product.service.remote.ProductExtractorException @@ -84,8 +86,9 @@ class HttpImageSnapshotExtractorTest { } @Test - fun `이미지 전용 422 code(IMAGE_UNSUPPORTED)도 별도 매핑 없이 확정 실패다 - 워커가 즉시 FAILED`() { - // 미지원 이미지 형식은 다시 보내도 결과가 같다. code 는 관측용이라 새 code 마다 매핑을 늘리지 않는다(tolerant reader). + fun `이미지 전용 422 code(IMAGE_UNSUPPORTED)는 확정 실패이며 extract_quality 로 센다`() { + // 미지원 이미지 형식은 다시 보내도 결과가 같다. 확정 실패라는 판정은 status(422)가 하고, code 가 정하는 건 + // "무엇으로 세는가"뿐이다 — 받은 결과를 상품 정보로 쓸 수 없다는 뜻이라 extract_quality 다(#936). val extractor = extractorWith { server -> server.expect(requestTo("http://extractor.test/internal/extractions/image")).andRespond( @@ -95,8 +98,9 @@ class HttpImageSnapshotExtractorTest { ) } - val e = assertFailsWith { extractor.extract(imageKey) } - assertEquals(ErrorCategory.SERVER_ERROR, e.category) + val e = assertFailsWith { extractor.extract(imageKey) } + assertEquals(ErrorCategory.INVALID_INPUT, e.category) + assertEquals(ItemParsingMetrics.REASON_EXTRACT_QUALITY, ItemParsingMetrics.reasonOf(e)) assertFalse(AsyncImageParsingWorker.isRetryable(e), "확정 실패는 워커가 재시도하면 안 된다") } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt index 050db1de..d3c96186 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt @@ -19,10 +19,13 @@ class AsyncItemParsingWorkerTest { @Test fun `RETRYABLE 이 아닌 HttpMappable 예외는 재시도 대상이 아니다(즉시 확정 실패)`() { - // 상품 아님·추출값 불신·원격 422 등 재시도해도 결정론적으로 재실패하는 것들. + // 원격 422 의 번역 결과 전부 — bucket 이 무엇이든(상품 아님·못 읽음·값 불신·대상 차단·조사 대상) + // 재시도 판정은 하나로 같다. reason 재편(#936)이 전이 판정을 건드리지 않았음을 여기서 고정한다. assertFalse(AsyncItemParsingWorker.isRetryable(ProductExtractorException.permanentFailure())) + assertFalse(AsyncItemParsingWorker.isRetryable(ProductExtractorException.blockedByTarget())) assertFalse(AsyncItemParsingWorker.isRetryable(ProductSnapshotException.notProductPage())) assertFalse(AsyncItemParsingWorker.isRetryable(ProductSnapshotException.untrustworthyValue())) + assertFalse(AsyncItemParsingWorker.isRetryable(ProductSnapshotException.noExtractableContent())) } @Test diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingMetricsTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingMetricsTest.kt new file mode 100644 index 00000000..72ab34bf --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingMetricsTest.kt @@ -0,0 +1,60 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.common.exception.BaseException +import com.depromeet.piki.common.exception.HttpMappable +import com.depromeet.piki.product.service.ExtractionFailureBucket +import com.depromeet.piki.product.service.ProductSnapshotException +import com.depromeet.piki.product.service.remote.ProductExtractorException +import kotlin.test.Test +import kotlin.test.assertEquals + +// 확정 실패 예외 → 메트릭 reason 라벨의 분기를 망라한다. 이 라벨이 곧 대시보드·알림의 축이라, 예외가 늘거나 +// bucket 배정이 바뀌면 여기가 먼저 깨져야 한다. (카탈로그와의 대조는 ExtractionErrorCatalogTest 가 따로 진다 — +// 여기는 "우리 예외가 어떤 라벨이 되나", 저기는 "그 라벨이 계약의 bucket 과 같나"를 본다.) +class ItemParsingMetricsTest { + // 확정 실패 예외 전량과 기대 라벨. bucket 5종을 하나씩 대표한다. + private val classified: List> = + listOf( + ProductSnapshotException.notProductPage() to ItemParsingMetrics.REASON_NOT_PRODUCT, + ProductSnapshotException.noExtractableContent() to ItemParsingMetrics.REASON_UNREADABLE, + ProductSnapshotException.untrustworthyValue() to ItemParsingMetrics.REASON_EXTRACT_QUALITY, + ProductExtractorException.blockedByTarget() to ItemParsingMetrics.REASON_BLOCKED, + ProductExtractorException.permanentFailure() to ItemParsingMetrics.REASON_INTERNAL_ERROR, + ) + + @Test + fun `확정 실패 예외는 자기 bucket 에 해당하는 reason 으로 집계된다`() { + classified.forEach { (e, reason) -> + val code = (e as HttpMappable).errorCode?.code + assertEquals(reason, ItemParsingMetrics.reasonOf(e), "$code 의 메트릭 reason") + } + } + + @Test + fun `bucket 5종이 서로 다른 reason 으로 빠짐없이 나뉜다`() { + // 두 bucket 이 같은 라벨로 뭉치면 "늘면 무엇을 하는가"가 다시 섞인다(#936 이 permanent_error 에서 겪은 문제). + val reasons = classified.map { (e, _) -> ItemParsingMetrics.reasonOf(e) }.toSet() + + assertEquals(ExtractionFailureBucket.entries.size, reasons.size, "bucket 수와 reason 라벨 수가 다르다: $reasons") + } + + @Test + fun `분류 밖 예외는 internal_error 로 집계된다`() { + // 코드 버그성 예외(HttpMappable 아님)·치명적 JVM 오류도 확정 실패 경로로 들어올 수 있다. 이름 없는 실패를 + // 다른 바구니에 섞지 않고 "조사 대상"으로 몰아, 다른 reason 의 추세를 오염시키지 않는다. + val internalError = ItemParsingMetrics.REASON_INTERNAL_ERROR + + assertEquals(internalError, ItemParsingMetrics.reasonOf(IllegalStateException("boom"))) + assertEquals(internalError, ItemParsingMetrics.reasonOf(NullPointerException())) + assertEquals(internalError, ItemParsingMetrics.reasonOf(OutOfMemoryError())) + } + + @Test + fun `bucket 이 없는 일시 실패 예외가 섞여 들어와도 internal_error 로 둔다`() { + // 일시 실패는 소유권 반납으로 되살아나 종결 집계에 닿지 않는다 — 여기 닿았다면 재시도 판정이 어긋난 것이라 + // 정상 분류가 아니라 조사 대상이다. + val transient = ProductExtractorException.transientFailure(RuntimeException("원격 502")) + + assertEquals(ItemParsingMetrics.REASON_INTERNAL_ERROR, ItemParsingMetrics.reasonOf(transient)) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt new file mode 100644 index 00000000..9cfa9c26 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt @@ -0,0 +1,120 @@ +package com.depromeet.piki.product.service.remote + +import com.depromeet.piki.item.service.ItemParsingMetrics +import org.yaml.snakeyaml.Yaml +import java.io.File +import kotlin.test.Test +import kotlin.test.fail + +/** + * 추출 실패 code 계약(정본: TeamPiKi/infra 의 `contracts/extraction-error-codes.yaml`)과 이 repo 의 번역·집계가 + * 어긋나지 않는지 기계로 강제하는 메타 테스트(#936). + * + * 강제하는 불변식 둘: + * 1. 카탈로그의 확정 실패(permanent) code 전수가 [RemoteExtractionContract.PERMANENT_TRANSLATIONS] 에 + * **명시 분기로** 있다 — 모르는 code 용 fallback 에 조용히 흡수되지 않는다. + * 2. 각 code 가 카탈로그의 `bucket` 과 **같은 이름의 메트릭 reason** 으로 귀결된다 — 계약의 분류와 대시보드의 + * 분류가 같은 어휘를 쓴다. + * + * 카탈로그는 CI 가 `shared-infra` 경로로 체크아웃하고(ci.yml), 로컬은 infra 의 install.sh 가 같은 경로에 설치한다. + * **파일이 없으면 skip 하지 않고 실패시킨다** — 없을 때 통과시키면 강제가 조용히 사라져, 어긋난 채로 CI 가 + * 초록불이 된다(그게 바로 이 테스트가 막으려는 상태다). + * + * Spring 컨텍스트·Docker 가 필요 없다: 카탈로그 파일과 순수 함수만 본다. + */ +class ExtractionErrorCatalogTest { + private data class CatalogCode( + val code: String, + val disposition: String, + val bucket: String?, + val scope: String?, + ) { + // 우리(파싱 파이프라인)가 번역해야 하는 대상 — 확정 실패이면서 프로브 전용(백오피스 모델 검증)이 아닌 것. + // 프로브 code 는 추출 응답이 아니라 모델 검증 응답이라 워커·메트릭에 닿지 않는다(HttpExtractionModelProbe 가 따로 번역). + val isParsingPermanent: Boolean get() = disposition == DISPOSITION_PERMANENT && scope != SCOPE_PROBE + } + + private val catalog: List by lazy { + val file = File(CATALOG_PATH) + if (!file.isFile) { + fail( + "추출 실패 code 계약 카탈로그를 찾지 못했다: ${file.absolutePath}\n" + + "CI 는 ci.yml 의 'Checkout extraction contract' 스텝이, 로컬은 infra 의 install.sh 가 $CATALOG_PATH 에 둔다. " + + "없다고 건너뛰면 계약 강제가 사라지므로 실패로 둔다.", + ) + } + val root = Yaml().load>(file.readText()) ?: fail("카탈로그가 비어 있다: ${file.absolutePath}") + val codes = root[KEY_CODES] as? Map<*, *> ?: fail("카탈로그에 '$KEY_CODES' 매핑이 없다: ${file.absolutePath}") + codes.map { (name, attributes) -> + val code = name.toString() + val fields = attributes as? Map<*, *> ?: fail("code '$code' 의 속성이 매핑이 아니다: $attributes") + CatalogCode( + code = code, + disposition = fields[KEY_DISPOSITION]?.toString() ?: fail("code '$code' 에 $KEY_DISPOSITION 이 없다"), + bucket = fields[KEY_BUCKET]?.toString(), + scope = fields[KEY_SCOPE]?.toString(), + ) + } + } + + @Test + fun `카탈로그의 확정 실패 code 는 전수가 translate 의 명시 분기로 있다`() { + val expected = catalog.filter { it.isParsingPermanent }.map { it.code }.toSet() + val mapped = RemoteExtractionContract.PERMANENT_TRANSLATIONS.keys + val missing = expected - mapped + val unknown = mapped - expected + + if (missing.isNotEmpty() || unknown.isNotEmpty()) { + fail( + buildString { + appendLine("RemoteExtractionContract.PERMANENT_TRANSLATIONS 이 카탈로그($CATALOG_PATH)와 어긋난다.") + if (missing.isNotEmpty()) { + appendLine( + "- 매핑 누락(모르는 code 용 fallback 으로 떨어져 internal_error 로 집계된다): " + + missing.sorted().joinToString(", "), + ) + } + if (unknown.isNotEmpty()) { + appendLine( + "- 카탈로그에 없는 code 를 매핑하고 있다(오타이거나 계약에서 사라진 code): " + + unknown.sorted().joinToString(", "), + ) + } + }, + ) + } + } + + @Test + fun `각 확정 실패 code 는 카탈로그 bucket 과 같은 메트릭 reason 으로 귀결된다`() { + // 카탈로그 bucket → 예외 → reason 라벨까지 실제 경로를 그대로 태운다. 문자열 대조가 아니라 산출물 대조라, + // 예외를 바꿔 다른 bucket 으로 새면(예: unreadable 을 not_product 예외로) 여기서 걸린다. + val mismatches = + catalog.filter { it.isParsingPermanent }.mapNotNull { entry -> + val bucket = entry.bucket ?: return@mapNotNull "${entry.code}: 카탈로그에 bucket 이 없다(확정 실패는 bucket 필수)" + val translate = RemoteExtractionContract.PERMANENT_TRANSLATIONS[entry.code] ?: return@mapNotNull null + val reason = ItemParsingMetrics.reasonOf(translate()) + reason.takeIf { it != bucket }?.let { "${entry.code}: 카탈로그 bucket=$bucket 인데 메트릭 reason=$it 로 집계된다" } + } + + if (mismatches.isNotEmpty()) { + fail( + "확정 실패 code 의 bucket 과 메트릭 reason 이 어긋난다 (카탈로그: $CATALOG_PATH):\n" + + mismatches.sorted().joinToString("\n"), + ) + } + } + + companion object { + // worktree·CI 러너 모두 저장소 루트가 작업 디렉터리다(Gradle Test 의 기본 workingDir). + private const val CATALOG_PATH = "shared-infra/contracts/extraction-error-codes.yaml" + + private const val KEY_CODES = "codes" + private const val KEY_DISPOSITION = "disposition" + private const val KEY_BUCKET = "bucket" + private const val KEY_SCOPE = "scope" + + private const val DISPOSITION_PERMANENT = "permanent" + private const val SCOPE_PROBE = "probe" + } +} diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt index dc16bc2f..51d6f7e3 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt @@ -1,7 +1,9 @@ package com.depromeet.piki.product.service.remote +import com.depromeet.piki.common.exception.BaseException import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.item.service.AsyncItemParsingWorker +import com.depromeet.piki.item.service.ItemParsingMetrics import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.routing.ExtractionRoute import com.depromeet.piki.product.routing.ExtractionRoutingPolicy @@ -286,25 +288,49 @@ class HttpProductLinkExtractorTest { val e = assertFailsWith { extractor.extract(link) } assertEquals(ErrorCategory.INVALID_INPUT, e.category) + assertEquals(ItemParsingMetrics.REASON_NOT_PRODUCT, ItemParsingMetrics.reasonOf(e)) assertFalse(AsyncItemParsingWorker.isRetryable(e), "확정 실패는 워커가 재시도하면 안 된다") } @Test - fun `422 UNTRUSTWORTHY_VALUE 도 기존 ProductSnapshotException 으로 되돌린다`() { - val extractor = - extractorWith { server -> - server.expect(requestTo("http://extractor.test/internal/extractions/link")).andRespond( - withStatus(HttpStatus.UNPROCESSABLE_ENTITY) - .contentType(MediaType.APPLICATION_JSON) - .body("""{"code":"UNTRUSTWORTHY_VALUE"}"""), - ) - } + fun `422 확정 실패 code 는 전이 판정은 그대로 둔 채 bucket 별 reason 으로만 갈린다`() { + // 원격 code 를 우리 예외로 번역하는 분기 망라(#936). code 마다 다른 건 **reason 뿐**이고, "422 = 확정 실패 + // (비 RETRYABLE)" 라는 전이 판정은 전부 같다 — 그 두 축이 섞이지 않았음을 한 테스트에서 함께 고정한다. + // 카탈로그 bucket 과 이 reason 이 같은지는 ExtractionErrorCatalogTest 가 별도로 대조한다. + val expected = + mapOf( + "NOT_PRODUCT_PAGE" to ItemParsingMetrics.REASON_NOT_PRODUCT, + "INVALID_URL" to ItemParsingMetrics.REASON_NOT_PRODUCT, + "EMPTY_SHELL" to ItemParsingMetrics.REASON_UNREADABLE, + "NO_EXTRACTABLE_CONTENT" to ItemParsingMetrics.REASON_UNREADABLE, + "FETCH_CLIENT_ERROR" to ItemParsingMetrics.REASON_BLOCKED, + "PERMANENT_UPSTREAM" to ItemParsingMetrics.REASON_BLOCKED, + "UNTRUSTWORTHY_VALUE" to ItemParsingMetrics.REASON_EXTRACT_QUALITY, + "LLM_INVALID_RESPONSE" to ItemParsingMetrics.REASON_EXTRACT_QUALITY, + "IMAGE_UNSUPPORTED" to ItemParsingMetrics.REASON_EXTRACT_QUALITY, + "BLOCKED_HOST" to ItemParsingMetrics.REASON_INTERNAL_ERROR, + "TOO_MANY_REDIRECTS" to ItemParsingMetrics.REASON_INTERNAL_ERROR, + "MALFORMED_REDIRECT" to ItemParsingMetrics.REASON_INTERNAL_ERROR, + ) + + expected.forEach { (code, reason) -> + val extractor = + extractorWith { server -> + server.expect(requestTo("http://extractor.test/internal/extractions/link")).andRespond( + withStatus(HttpStatus.UNPROCESSABLE_ENTITY) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"code":"$code"}"""), + ) + } - assertFailsWith { extractor.extract(link) } + val e = assertFailsWith { extractor.extract(link) } + assertEquals(reason, ItemParsingMetrics.reasonOf(e), "$code 의 메트릭 reason") + assertFalse(AsyncItemParsingWorker.isRetryable(e), "$code 는 422 라 재시도 대상이 아니어야 한다") + } } @Test - fun `422 의 모르는 code 도 확정 실패다 (tolerant reader) - 비 RETRYABLE 로 워커가 즉시 FAILED`() { + fun `422 의 모르는 code 도 확정 실패다 (tolerant reader) - internal_error 로 세어 매핑 누락이 드러난다`() { val extractor = extractorWith { server -> server.expect(requestTo("http://extractor.test/internal/extractions/link")).andRespond( @@ -316,6 +342,8 @@ class HttpProductLinkExtractorTest { val e = assertFailsWith { extractor.extract(link) } assertEquals(ErrorCategory.SERVER_ERROR, e.category) + // 이름을 모르는 실패를 다른 바구니에 섞지 않는다 — 조사 대상(internal_error)으로 센다. + assertEquals(ItemParsingMetrics.REASON_INTERNAL_ERROR, ItemParsingMetrics.reasonOf(e)) assertFalse(AsyncItemParsingWorker.isRetryable(e)) } @@ -331,6 +359,7 @@ class HttpProductLinkExtractorTest { } val e = assertFailsWith { extractor.extract(link) } + assertEquals(ItemParsingMetrics.REASON_INTERNAL_ERROR, ItemParsingMetrics.reasonOf(e)) assertFalse(AsyncItemParsingWorker.isRetryable(e)) } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt index 80d96c94..a727cb8c 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -575,22 +575,23 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `URL 파싱이 영구 외부 오류(차단된 호스트·접근 불가)면 즉시 FAILED 로 종결한다`() { + fun `URL 파싱이 대상 차단으로 확정 실패하면 즉시 FAILED 로 종결하고 blocked 로 센다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { // 재시도해도 결정론적으로 재실패하는 영구 오류(원격 422 확정 실패)는 recover 를 기다리지 않고 // (약 150초 헛돔 방지) 워커가 즉시 FAILED 로 종결한다. recover 는 stale(60초) 후에야 돌므로 5초 내 FAILED 면 즉시 종결이다. - stubProductLinkExtractor.build = { throw ProductExtractorException.permanentFailure() } - val permanentBefore = parseCount("failed", "permanent_error") + stubProductLinkExtractor.build = { throw ProductExtractorException.blockedByTarget() } + val blockedBefore = parseCount("failed", "blocked") val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/blocked") await().atMost(Duration.ofSeconds(5)).until { latestSnapshot(itemId)?.status == ItemStatus.FAILED } - // 결과 메트릭(#506): 재시도 무의미한 영구 외부 오류 확정 실패는 result=failed,reason=permanent_error 로 +1. - await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "permanent_error") - permanentBefore >= 1.0 } + // 결과 메트릭(#506·#936): 대상이 막아 확정 실패한 건은 result=failed,reason=blocked 로 +1. + // reason 이 예외에서 파생되므로(ItemParsingMetrics.reasonOf), 이 단언이 워커→메트릭 배선까지 함께 고정한다. + await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "blocked") - blockedBefore >= 1.0 } val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") assertEquals(ItemStatus.FAILED, snapshot.status)