Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ enum class ExtractionFailureBucket {
// 사용자가 상품 아닌 걸 넣었다. 정상 트래픽이라 할 일이 없다.
NOT_PRODUCT,

// 우리 구성으로 그 페이지를 못 읽었다(빈 셸·추출할 본문 없음). 늘면 도메인 허가 후보를 본다.
// 우리 구성으로 그 페이지를 못 읽었다. 계약이 그 code 들을 일시로 돌려(infra#145) 지금은 부여 code 없음.
UNREADABLE,

// 대상이 우리를 막았다. 늘면 BLOCKED 정책 후보를 본다.
// 대상이 우리를 막았다. 계약이 그 code 들을 일시로 돌려(infra#145) 지금은 부여 code 없음.
BLOCKED,

// 추출은 됐는데 값을 믿을 수 없다. 늘면 모델·프롬프트·검증 규칙을 본다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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).
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).
//
Expand All @@ -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 갱신)라 결국 코드 작업으로 귀결된다.
Expand All @@ -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).
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 는 클래스 모양 통일 목적이며, 비동기 워커 전용이라 공개 카탈로그에 등록하지 않는다.
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,17 @@ internal object RemoteExtractionContract {
//
// 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode),
// ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구).
//
// 확정은 링크 자체의 사실과 우리 쪽 차단뿐이다. 받은 페이지를 보고 내린 판정(상품 아님·빈 셸·4xx·값 불신)은
// 계약이 일시로 두므로 여기 오지 않는다 — 그 건은 attempt 소진으로 닫히고 code 별 관측은 extractor 메트릭이 진다.
internal val PERMANENT_TRANSLATIONS: Map<ExtractionErrorCode, () -> 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 확정 / 그 외 일시)가 이 함수 안에서 끝난다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,13 +11,11 @@ import kotlin.test.assertEquals
// bucket 배정이 바뀌면 여기가 먼저 깨져야 한다. (계약과의 대조는 ExtractionErrorCatalogTest 가 따로 진다 —
// 여기는 "우리 예외가 어떤 라벨이 되나", 저기는 "그 라벨이 계약의 bucket 과 같나"를 본다.)
class ItemParsingMetricsTest {
// 확정 실패 예외 전량과 기대 라벨. bucket 5종을 하나씩 대표한다.
// 확정 실패 예외 전량과 기대 라벨. 부여 code 가 남은 bucket 3종을 하나씩 대표한다.
private val classified: List<Pair<BaseException, String>> =
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,
)

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"""),
)
}

Expand All @@ -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) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading