From d671472246897184f002c99c2e559b14b9a0e2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:22:14 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B4=80=EC=B8=A1=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=8B=A4=ED=8C=A8=20code=20=EC=9D=98=20=EC=9D=BC?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 422 번역 표에서 code 9개 제거, INVALID_URL·IMAGE_UNSUPPORTED·BLOCKED_HOST 만 남김 - 원인: 계약(TeamPiKi/infra#145)이 받은 페이지 기반 판정을 전부 TRANSIENT 로 전환 - 결과: 그 건은 502 로 와 재시도 뒤 retry_exhausted 로 집계 - 번역 표에서만 쓰던 예외 팩토리·code 삭제 - SNAPSHOT-003(읽을 내용 없음)·EXTRACTOR-003(대상 차단)은 결번 - bucket UNREADABLE·BLOCKED 는 계약 enum 과 1:1 이라 유지, 부여 code 0 - 전이 판정은 status 전용이라 변경 없음 --- .../service/ExtractionFailureBucket.kt | 4 ++-- .../service/ProductSnapshotErrorCode.kt | 13 ++---------- .../service/ProductSnapshotException.kt | 6 +----- .../remote/ProductExtractorErrorCode.kt | 13 +++--------- .../remote/ProductExtractorException.kt | 8 ++----- .../remote/RemoteExtractionContract.kt | 21 ++++++------------- .../service/AsyncItemParsingWorkerTest.kt | 4 +--- .../item/service/ItemParsingMetricsTest.kt | 9 +++----- .../remote/HttpProductLinkExtractorTest.kt | 13 ++---------- .../WishlistRegisterAsyncIntegrationTest.kt | 10 ++++----- 10 files changed, 27 insertions(+), 74 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt index 126eb4fbe..dc28b2c42 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt @@ -11,10 +11,10 @@ enum class ExtractionFailureBucket { // 사용자가 상품 아닌 걸 넣었다. 정상 트래픽이라 할 일이 없다. NOT_PRODUCT, - // 우리 구성으로 그 페이지를 못 읽었다(빈 셸·추출할 본문 없음). 늘면 도메인 허가 후보를 본다. + // 우리 구성으로 그 페이지를 못 읽었다. 계약이 그 code 들을 일시로 돌려(infra#145) 지금은 부여 code 없음. UNREADABLE, - // 대상이 우리를 막았다. 늘면 BLOCKED 정책 후보를 본다. + // 대상이 우리를 막았다. 계약이 그 code 들을 일시로 돌려(infra#145) 지금은 부여 code 없음. BLOCKED, // 추출은 됐는데 값을 믿을 수 없다. 늘면 모델·프롬프트·검증 규칙을 본다. 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 2b86cc439..1128bb94f 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotErrorCode.kt @@ -19,7 +19,7 @@ enum class ProductSnapshotErrorCode( override val message: String, override val bucket: ExtractionFailureBucket, ) : ExtractionFailureCode { - // LLM 이 "상품 페이지가 아님"으로 판정. 링크 재등록·재시도 모두 무의미. + // 형식 위반·없는 host 라 상품 링크가 될 수 없음. 링크 재등록·재시도 모두 무의미. NOT_PRODUCT_PAGE( "SNAPSHOT-001", ErrorCategory.INVALID_INPUT, @@ -38,14 +38,5 @@ enum class ProductSnapshotErrorCode( ExtractionFailureBucket.EXTRACT_QUALITY, ), - // 우리가 그 페이지에서 읽어낼 본문을 얻지 못함(데이터 없는 CSR 셸·가시 텍스트 부재). 상품이 아닌 게 아니라 - // **지금 우리 구성으로 못 읽는** 것이라 도메인 허가 후보를 찾는 신호다. - // message 는 UNTRUSTWORTHY_VALUE 와 같다 — 사용자가 취할 행동(직접 입력)이 같고, 구분은 detail 이 아니라 - // bucket·로그가 진다(CLAUDE.md 메시지 톤). - NO_EXTRACTABLE_CONTENT( - "SNAPSHOT-003", - ErrorCategory.INVALID_INPUT, - "상품 정보를 확인하지 못했어요. 직접 입력해 주세요.", - ExtractionFailureBucket.UNREADABLE, - ), + // SNAPSHOT-003 은 결번 — 받은 페이지 기반 판정(읽을 내용 없음)이 일시 실패로 바뀌며 소멸(infra#145). } 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 2a9ce874d..41893319e 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshotException.kt @@ -18,14 +18,10 @@ class ProductSnapshotException private constructor( override val httpStatus: HttpStatus get() = errorCode.category.httpStatus companion object { - // LLM 이 "상품 페이지가 아님"으로 판정. 링크 재등록·재시도 모두 무의미. + // 형식 위반·없는 host 라 상품 링크가 될 수 없음. 재시도 무의미. fun notProductPage(): ProductSnapshotException = ProductSnapshotException(ProductSnapshotErrorCode.NOT_PRODUCT_PAGE) // 추출값이 유효 범위(가격 음수, 컬럼 길이 초과 등)를 벗어남. 추출 결과를 신뢰할 수 없다. 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 2aad7c92b..46e6f247e 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 @@ -10,7 +10,7 @@ import com.depromeet.piki.product.service.ExtractionFailureCode // 생성 경로인 RemoteExtractionContract 의 유일한 소비자가 비동기 파싱 워커라, GlobalExceptionHandler 를 거치지 // 않고 워커의 재시도 판정(isRetryable)·item FAILED 전이로만 관측된다. 클라 대면 공개 카탈로그 대상이 아니다. // -// 세 사유가 같은 message 를 공유한다 — 원격이 왜 실패했는지는 사용자 관심사가 아니고, 구분은 category·bucket·로그가 진다. +// 두 사유가 같은 message 를 공유한다 — 원격이 왜 실패했는지는 사용자 관심사가 아니고, 구분은 category·bucket·로그가 진다. // 재시도 여부만 갈린다: TRANSIENT_FAILURE 는 RETRYABLE(워커가 소유권을 반납해 다음 tick 이 재실행), // 나머지는 비 RETRYABLE(즉시 FAILED). // @@ -29,7 +29,7 @@ enum class ProductExtractorErrorCode( // 원격 호출이 일시적으로 실패(5xx·타임아웃·연결 실패·빈 응답·2xx 계약 위반). TRANSIENT_FAILURE("EXTRACTOR-001", ErrorCategory.RETRYABLE, "상품 정보를 가져오지 못했어요.", null), - // 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 모르는 code 로 422 가 온 경우. + // 우리 방어가 발동했거나(사설망 호스트 차단) 이 바이너리가 모르는 code 로 422 가 온 경우. // tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미. // 둘 다 "코드를 조사한다"가 대응이라 internal_error 로 센다: 전자는 우리 방어·버그이고, 후자는 매핑이 // 뒤처졌다는 신호(계약·translate 갱신)라 결국 코드 작업으로 귀결된다. @@ -40,12 +40,5 @@ enum class ProductExtractorErrorCode( ExtractionFailureBucket.INTERNAL_ERROR, ), - // 대상이 우리를 막아 확정 실패. 우리 버그도 사용자 잘못도 아니라 따로 센다 — 늘면 그 도메인의 - // BLOCKED 정책(백오피스) 후보가 된다. - BLOCKED_BY_TARGET( - "EXTRACTOR-003", - ErrorCategory.SERVER_ERROR, - "상품 정보를 가져오지 못했어요.", - ExtractionFailureBucket.BLOCKED, - ), + // EXTRACTOR-003 은 결번 — 대상 차단(4xx·5xx)이 일시 실패로 바뀌며 소멸(infra#145). } 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 83a6dc41a..dfc2200f2 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,7 +8,7 @@ import org.springframework.http.HttpStatus // 원격 추출기(extractor, 도메인 용어 product 의 ProductExtractor) 호출 실패. 워커(AsyncItemParsingWorker.isRetryable)의 // 재시도 판정이 category 만 보므로, extractor 계약의 3갈래 중 "일시(그 외 전부)"는 RETRYABLE 로, "확정(422)"는 SERVER_ERROR 로 번역한다. -// (확정 실패라도 "이 링크로는 상품 스냅샷을 만들 수 없다"는 사유 — 상품 아님·못 읽음·값 불신 — 는 이 예외가 +// (확정 실패라도 "이 링크로는 상품 스냅샷을 만들 수 없다"는 사유 — 링크 형식·파일 형식 — 는 이 예외가 // 아니라 ProductSnapshotException 으로 되돌린다. 어느 code 가 어디로 가는지는 RemoteExtractionContract 참고.) // message·category·httpStatus 는 전부 errorCode 하나에서 파생한다(ProductExtractorErrorCode 가 single source). // errorCode 는 클래스 모양 통일 목적이며, 비동기 워커 전용이라 공개 카탈로그에 등록하지 않는다. @@ -25,13 +25,9 @@ class ProductExtractorException private constructor( fun transientFailure(cause: Throwable?): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.TRANSIENT_FAILURE, cause) - // 원격이 422(확정 실패)로 답했고, 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 + // 원격이 422(확정 실패)로 답했고, 우리 방어가 발동했거나(사설망 호스트 차단) 이 바이너리가 // 모르는 code 인 경우. tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). // 재시도 무의미이므로 비 RETRYABLE. fun permanentFailure(): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.PERMANENT_FAILURE) - - // 원격이 422 로 답했고, 그 사유가 "대상이 우리를 막았다"인 경우(4xx 접근 거부·영구 upstream 거절). - // 재시도 무의미인 건 같고, 메트릭에서 blocked 로 따로 세어 정책(BLOCKED) 판단의 입력이 된다. - 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 635b107c5..f732c0df6 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 @@ -43,26 +43,17 @@ internal object RemoteExtractionContract { // // 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode), // ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구). + // + // 확정은 링크 자체의 사실과 우리 쪽 차단뿐이다. 받은 페이지를 보고 내린 판정(상품 아님·빈 셸·4xx·값 불신)은 + // 계약이 일시로 두므로 여기 오지 않는다 — 그 건은 attempt 소진으로 닫히고 code 별 관측은 extractor 메트릭이 진다. internal val PERMANENT_TRANSLATIONS: Map BaseException> = mapOf( - // 사용자가 상품 아닌 걸 넣었다 — 정상 트래픽. - ExtractionErrorCode.NOT_PRODUCT_PAGE to { ProductSnapshotException.notProductPage() }, + // 형식 위반·없는 host — 사용자가 상품 링크가 아닌 걸 넣었다. ExtractionErrorCode.INVALID_URL to { ProductSnapshotException.notProductPage() }, - // 우리 구성으로 못 읽었다 — 도메인 허가 후보 신호. - ExtractionErrorCode.EMPTY_SHELL to { ProductSnapshotException.noExtractableContent() }, - ExtractionErrorCode.NO_EXTRACTABLE_CONTENT to { ProductSnapshotException.noExtractableContent() }, - // 대상이 우리를 막았다 — BLOCKED 정책 후보. - ExtractionErrorCode.FETCH_CLIENT_ERROR to { ProductExtractorException.blockedByTarget() }, - ExtractionErrorCode.PERMANENT_UPSTREAM to { ProductExtractorException.blockedByTarget() }, - // 추출은 됐는데 값을 믿을 수 없다 — 모델·프롬프트·검증 규칙 소관. - // IMAGE_UNSUPPORTED(이미지 경로 전용)도 "받은 결과를 상품 정보로 쓸 수 없다"는 같은 성격이다. - ExtractionErrorCode.UNTRUSTWORTHY_VALUE to { ProductSnapshotException.untrustworthyValue() }, - ExtractionErrorCode.LLM_INVALID_RESPONSE to { ProductSnapshotException.untrustworthyValue() }, + // 이미지 경로 전용 — 받은 파일을 상품 정보로 쓸 수 없다. ExtractionErrorCode.IMAGE_UNSUPPORTED to { ProductSnapshotException.untrustworthyValue() }, - // 우리 방어가 발동했다 — 정상 흐름이면 애초에 우리 경계(SSRF 가드·리다이렉트 제한)가 먼저 걸렀어야 한다. + // 우리 방어(SSRF 가드)가 발동했다 — 정상 흐름이면 우리 경계가 먼저 걸렀어야 한다. ExtractionErrorCode.BLOCKED_HOST to { ProductExtractorException.permanentFailure() }, - ExtractionErrorCode.TOO_MANY_REDIRECTS to { ProductExtractorException.permanentFailure() }, - ExtractionErrorCode.MALFORMED_REDIRECT to { ProductExtractorException.permanentFailure() }, ) // 원격 추출 호출 한 건의 전부 — POST 부터 3갈래(2xx 매핑 / 422+code 확정 / 그 외 일시)가 이 함수 안에서 끝난다. 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 cd65edcb2..84530aa60 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorkerTest.kt @@ -19,13 +19,11 @@ class AsyncItemParsingWorkerTest { @Test fun `RETRYABLE 이 아닌 HttpMappable 예외는 재시도 대상이 아니다(즉시 확정 실패)`() { - // 원격 422 의 번역 결과 전부 — bucket 이 무엇이든(상품 아님·못 읽음·값 불신·대상 차단·조사 대상) + // 원격 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 index 236b80821..24f1196f4 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingMetricsTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingMetricsTest.kt @@ -2,7 +2,6 @@ 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 @@ -12,13 +11,11 @@ import kotlin.test.assertEquals // bucket 배정이 바뀌면 여기가 먼저 깨져야 한다. (계약과의 대조는 ExtractionErrorCatalogTest 가 따로 진다 — // 여기는 "우리 예외가 어떤 라벨이 되나", 저기는 "그 라벨이 계약의 bucket 과 같나"를 본다.) class ItemParsingMetricsTest { - // 확정 실패 예외 전량과 기대 라벨. bucket 5종을 하나씩 대표한다. + // 확정 실패 예외 전량과 기대 라벨. 부여 code 가 남은 bucket 3종을 하나씩 대표한다. 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, ) @@ -31,11 +28,11 @@ class ItemParsingMetricsTest { } @Test - fun `bucket 5종이 서로 다른 reason 으로 빠짐없이 나뉜다`() { + fun `부여 code 가 남은 bucket 은 서로 다른 reason 으로 나뉜다`() { // 두 bucket 이 같은 라벨로 뭉치면 "늘면 무엇을 하는가"가 다시 섞인다(#936 이 permanent_error 에서 겪은 문제). val reasons = classified.map { (e, _) -> ItemParsingMetrics.reasonOf(e) }.toSet() - assertEquals(ExtractionFailureBucket.entries.size, reasons.size, "bucket 수와 reason 라벨 수가 다르다: $reasons") + assertEquals(classified.size, reasons.size, "예외 수와 reason 라벨 수가 다르다: $reasons") } @Test 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 5473dca0b..6955959b7 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 @@ -228,13 +228,13 @@ class HttpProductLinkExtractorTest { } @Test - fun `422 NOT_PRODUCT_PAGE 는 기존 ProductSnapshotException 으로 되돌려 워커 의미(not_product)를 보존한다`() { + fun `422 INVALID_URL 은 기존 ProductSnapshotException 으로 되돌려 워커 의미(not_product)를 보존한다`() { val extractor = extractorWith { server -> server.expect(requestTo("http://extractor.test/internal/extractions/link")).andRespond( withStatus(HttpStatus.UNPROCESSABLE_ENTITY) .contentType(MediaType.APPLICATION_JSON) - .body("""{"code":"NOT_PRODUCT_PAGE"}"""), + .body("""{"code":"INVALID_URL"}"""), ) } @@ -251,18 +251,9 @@ class HttpProductLinkExtractorTest { // 계약 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) -> 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 01e4d2a53..1d902ece6 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -563,21 +563,21 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `URL 파싱이 대상 차단으로 확정 실패하면 즉시 FAILED 로 종결하고 blocked 로 센다`() { + fun `URL 파싱이 우리 방어 차단으로 확정 실패하면 즉시 FAILED 로 종결하고 internal_error 로 센다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { // 재시도해도 결정론적으로 재실패하는 영구 오류(원격 422 확정 실패)는 recover 를 기다리지 않고 // (약 150초 헛돔 방지) 워커가 즉시 FAILED 로 종결한다. - stubProductLinkExtractor.build = { throw ProductExtractorException.blockedByTarget() } - val blockedBefore = parseCount("failed", "blocked") + stubProductLinkExtractor.build = { throw ProductExtractorException.permanentFailure() } + val internalErrorBefore = parseCount("failed", "internal_error") val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/blocked") itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.FAILED } - // 결과 메트릭(#506·#936): 대상이 막아 확정 실패한 건은 result=failed,reason=blocked 로 +1. + // 결과 메트릭(#506·#936): 우리 방어(SSRF 가드)로 확정 실패한 건은 result=failed,reason=internal_error 로 +1. // reason 이 예외에서 파생되므로(ItemParsingMetrics.reasonOf), 이 단언이 워커→메트릭 배선까지 함께 고정한다. - await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "blocked") - blockedBefore >= 1.0 } + await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "internal_error") - internalErrorBefore >= 1.0 } val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") assertEquals(ItemStatus.FAILED, snapshot.status)