From 833ee0b56af43a901d03d28a805d7be315e7aa03 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:04:54 +0900
Subject: [PATCH 1/2] =?UTF-8?q?fix:=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
- 팩토리 9개의 permanent 플래그를 false 로 전환, 응답 422 에서 502 로
- 원인: 받은 페이지를 보고 내린 판정은 봇 방어·앱 크래시·빈 셸로 재료가 가짜일 수 있음
- 결과: 호출자가 재시도 뒤 attempt 소진으로 닫음
- 참조: TeamPiKi/infra#145
- Gemini 예외에 retryable 축 추가, GeminiRetry·모델 프로브가 permanent 대신 그것을 봄
- 원인: LLM_INVALID_RESPONSE 가 일시가 되면 같은 요청의 즉시 재호출이 늘어남
- 결과: Gemini 호출 수 이전과 동일
- 에스컬레이션 축은 변경 없음
- 확정 유지는 INVALID_URL·BLOCKED_HOST·IMAGE_UNSUPPORTED·MODEL_*
- 확정 실패를 전제한 주석 정정
---
.../common/exception/ExtractionErrorCode.java | 4 +--
.../extractor/domain/ProductSnapshot.java | 6 ++--
.../domain/ProductSnapshotException.java | 4 +--
.../FallbackProductLinkExtractor.java | 13 ++++----
.../HeadlessProductLinkExtractor.java | 4 +--
.../extraction/HtmlSnapshotPipeline.java | 2 +-
.../extractor/extraction/LlmInputGate.java | 4 +--
.../extraction/gemini/GeminiApiException.java | 32 +++++++++++--------
.../extraction/gemini/GeminiHttpClient.java | 2 +-
.../extraction/gemini/GeminiRetry.java | 4 +--
.../extraction/http/HttpPageFetcher.java | 6 ++--
.../extraction/http/PageFetchException.java | 28 ++++++++--------
.../structured/StructuredDataExtractor.java | 2 +-
.../extractor/probe/ModelProbeService.java | 4 +--
.../api/ExtractionLinkIntegrationTest.java | 18 +++++------
.../DefaultProductLinkExtractorTest.java | 3 +-
.../gemini/GeminiApiExceptionTest.java | 19 +++++------
.../extraction/gemini/GeminiRetryTest.java | 4 +--
.../http/HttpPageFetcherServerErrorTest.java | 16 +++++-----
19 files changed, 91 insertions(+), 84 deletions(-)
diff --git a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java
index 9e70823..b28e3fa 100644
--- a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java
+++ b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java
@@ -18,8 +18,8 @@ public enum ExtractionErrorCode {
/** fetch 는 2xx 였지만 본문이 데이터 없는 CSR 셸 — 파싱 no-data 를 escalatable 로 재분류한 것(EmptyShellDetector). */
EMPTY_SHELL,
/**
- * 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정한 것(LlmInputGate) — 빈 입력의 LLM 은
- * 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL 과 확정/일시 축에서는 같고(둘 다 확정), 갈리는 축은
+ * 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 실패로 닫은 것(LlmInputGate) — 빈 입력의 LLM 은
+ * 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL 과 확정/일시 축에서는 같고(둘 다 일시), 갈리는 축은
* 에스컬레이션이다 — EMPTY_SHELL 은 브라우저면 뚫릴 수 있어 승격되지만({@code PageFetchException.emptyShell}
* 의 escalatable 이 정본), 이 code 는 승격 대상이 아니다. plain 경로는 셸 재분류가 선행하므로, 이 code 는
* 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다.
diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
index 71a1597..d50c63c 100644
--- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
+++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
@@ -40,7 +40,7 @@ public ProductSnapshot withOrigin(ProductLink finalUrl, ExtractionMethod method)
/**
* 호출자(core)의 READY 불변식(name·imageUrl·currentPrice)을 채우지 못했는가 — currency 는 READY 필수가 아니다.
* 응답 경계(ExtractionResultMapper)의 성공 게이트와 헤드리스 에스컬레이션 판정(FallbackProductLinkExtractor)이
- * 이 판정 하나를 공유한다 — 두 곳의 조건이 어긋나면 "승격 없이 확정 실패" 또는 "무의미한 승격"이 생긴다.
+ * 이 판정 하나를 공유한다 — 두 곳의 조건이 어긋나면 "승격 없이 실패" 또는 "무의미한 승격"이 생긴다.
*/
public boolean missingReadyField() {
return name == null || name.isBlank() || imageUrl == null || currentPrice == null;
@@ -50,7 +50,7 @@ public boolean missingReadyField() {
* 추출값을 하나도 못 얻었는가 — 호출자에게 내려보낼 것도, 사용자에게 "무엇을 채우라" 할 근거도 없는 상태다.
*
*
부분값(일부만 채움)은 호출자가 INCOMPLETE 로 수용해 사용자가 나머지를 채우므로(TeamPiKi/core#944)
- * 성공으로 내려보내고, 이 판정이 참일 때만 확정 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
+ * 성공으로 내려보내고, 이 판정이 참일 때만 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
* "건졌다"의 근거가 되지 못하므로 세지 않는다.
*/
public boolean hasNoExtractedValue() {
@@ -84,7 +84,7 @@ public String missingFieldNames() {
* imageUrl 을 https 로 좁히는 것은 클라이언트가 {@code
} 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
* 다만 좁히는 방식은 스킴에 따라 다르다 — 자세한 근거는 normalizeImageUrl 참조.
*
범위를 벗어난 값은 {@link ProductSnapshotException#untrustworthyValue()} 로 막고, 그 뒤 처리는 호출부가 고른다:
- * 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 확정 실패로 떨어뜨린다.
+ * 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 실패로 떨어뜨린다.
* 같은 검증, 실패 표현만 다르다.
*/
public static ProductSnapshot fromExtracted(
diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java
index d0854a8..652dad0 100644
--- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java
+++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java
@@ -3,11 +3,11 @@
import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode;
import com.depromeet.piki.extractor.common.exception.ExtractionException;
-/** 전부 호출자가 재시도해도 얻을 것이 없는 사유라 확정 실패로 답한다. */
+/** 전부 받은 문서를 보고 내린 판정이라 일시 실패로 답한다 — 재료가 봇 방어·앱 크래시·빈 셸로 가짜일 수 있다(infra#145). */
public final class ProductSnapshotException extends ExtractionException {
private ProductSnapshotException(String message, ExtractionErrorCode code) {
- super(message, code, true, null);
+ super(message, code, false, null);
}
public static ProductSnapshotException notProductPage() {
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java
index 57cdcde..3ac6c08 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java
@@ -24,9 +24,8 @@
* 일이라 허락을 전제하지 않는다. authorized 를 쓰는 곳은 렌더 서비스뿐이고,
* 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다).
*
- *
에스컬레이션 축(plain 확정 → headless)은 호출자 파싱 작업 큐의 재시도 축(일시 오류 → 같은 plain 재시도)과
- * 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서
- * 여기서 별도로 판정한다.
+ *
에스컬레이션 축(plain 실패 → headless)은 호출자 파싱 작업 큐의 재시도 축(같은 plain 재시도)과 직교한다.
+ * 호출자 재시도는 브라우저를 열지 않으므로, 브라우저가 필요한 건은 한 시도 안에서 여기서 승격한다.
*/
@Slf4j
@RequiredArgsConstructor
@@ -67,9 +66,9 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode
return escalateToHeadless(link, categoryOf(e), authorized, model);
}
if (plainSnapshot.missingReadyField()) {
- // 이대로 반환하면 응답 경계(ExtractionResultMapper.from)가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는다 —
- // 확정 전에 브라우저 렌더 DOM 으로 한 번 더 시도한다. 승격은 plain 의 try 바깥이라 headless 실패가
- // 위 catch 로 새어 재승격되는 일이 없고, 승격 결과가 여전히 불완전하면 그때 경계가 같은 확정 실패로
+ // 이대로 반환하면 응답 경계(ExtractionResultMapper.from)가 실패(UNTRUSTWORTHY_VALUE)로 닫는다 —
+ // 닫기 전에 브라우저 렌더 DOM 으로 한 번 더 시도한다. 승격은 plain 의 try 바깥이라 headless 실패가
+ // 위 catch 로 새어 재승격되는 일이 없고, 승격 결과가 여전히 불완전하면 그때 경계가 같은 실패로
// 닫는다(재승격 없음).
return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, authorized, model);
}
@@ -114,7 +113,7 @@ private Counter escalationCounter(String outcome, String category) {
}
/**
- * headless 결과의 성공 판정. 예외 없이 반환됐어도 READY 필수 필드가 비면 응답 경계에서 확정 실패로 닫히는
+ * headless 결과의 성공 판정. 예외 없이 반환됐어도 READY 필수 필드가 비면 응답 경계에서 실패로 닫히는
* 결과다 — 그걸 success 로 세면 구제 성공률(escalation)·직행 성공률(headless_first)이 실제보다 부푼다.
* outcome 은 예외 여부가 아니라 "요청을 살렸는가"를 센다.
*/
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java
index 2fa6a1d..78f830f 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java
@@ -26,7 +26,7 @@
*
차단 신호는 후보를 거르지 않는다. 403 뒤에 온전한 상품 JSON-LD 가 실려 오기도 하므로(봇 방어의 위장 status)
* 구조화 데이터는 그대로 쓰고, 차단으로 보이는 문서는 LLM 후보에서만 뺀다. 아무것도 못 뽑았을 때 실패 코드를
* 차단과 장애로 가르는 데만 쓴다. 상품 경로가 사이트 루트에 착지한 홉도 같은 취급이다 — 홈 피드를 LLM 에 보내면
- * 확정 실패로 닫혀 버리므로 후보에서 빼고, 그것뿐이면 일시 실패로 남긴다(HttpPageFetcher 의 redirect 판정과 짝).
+ * 상품 아님으로 닫혀 버리므로 후보에서 빼고, 그것뿐이면 장애 쪽 code 로 남긴다(HttpPageFetcher 의 redirect 판정과 짝).
*/
@Slf4j
@RequiredArgsConstructor
@@ -52,7 +52,7 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode
boolean blocked = hops.stream().anyMatch(hop -> HeadlessBlockSignal.isBlocked(hop.status(), hop.headers()));
boolean rootLanded = false;
- Chosen first = null; // 차단 신호 없는 첫 후보 — 전부 셸이면 이걸로 게이트가 확정 실패를 닫는다
+ Chosen first = null; // 차단 신호 없는 첫 후보 — 전부 셸이면 이걸로 게이트가 실패를 닫는다
Chosen fallback = null; // 그중 LLM 에 넘길 것이 있는 첫 후보
for (Candidate candidate : candidates(hops)) {
PageContent page = PageContent.of(link, candidate.html(), candidate.hop().url());
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java
index dff35a6..d818881 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java
@@ -13,7 +13,7 @@
/**
* HTML → ProductSnapshot 의 공통 후반부: 구조화 데이터(JSON-LD/OpenGraph) 우선, 미달이면 같은 HTML 을 Gemini 로
- * 넘긴다(재fetch 없음). 단 LLM 이 볼 게 아무것도 없는 문서({@link LlmInputGate})는 넘기지 않고 확정 실패로
+ * 넘긴다(재fetch 없음). 단 LLM 이 볼 게 아무것도 없는 문서({@link LlmInputGate})는 넘기지 않고 실패로
* 끊는다 — 빈 입력의 LLM 은 실존하지 않는 상품을 지어내고, 그 값은 형식이 유효해 하류 검증을 전부 통과한다.
* HTML 을 어디서 얻었는지(정적 fetch, 헤드리스 렌더)와 무관한 파싱 파이프라인이라 두 전략이 공유한다.
*
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/LlmInputGate.java b/src/main/java/com/depromeet/piki/extractor/extraction/LlmInputGate.java
index bf3be86..e2a5fe5 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/LlmInputGate.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/LlmInputGate.java
@@ -10,14 +10,14 @@
* 입력이 비었음을 아는 이 지점이 유일한 차단 기회다.
*
*
{@link EmptyShellDetector}(에스컬레이션 축)와 판정을 분리하는 이유는 오탐 비용의 비대칭이다 — 그쪽
- * 오탐은 헤드리스 1회 낭비(fail-open)로 그치지만, 이 게이트의 오탐은 확정 실패(422)로 굳는다. 그래서 길이
+ * 오탐은 헤드리스 1회 낭비(fail-open)로 그치지만, 이 게이트의 오탐은 재시도를 거쳐도 같은 실패로 굳는다. 그래서 길이
* 임계("짧다") 같은 판단을 두지 않고 사실만 본다: 가시 텍스트가 전혀 없고, 데이터 script(JSON-LD·data
* island — {@link GeminiHtmlExtractor} sanitize 가 보존하는 것)도 없다. 환각 사고 케이스는 렌더 후에도
* 0자였다. jsoup 의 {@code text()} 는 script 내용을 세지 않으므로 데이터 script 유무는 별도 조건이 진다.
*
*
수용한 잔존 위험: 가시 텍스트가 몇십 자(접근성 링크·푸터 보일러플레이트)뿐인 셸은 게이트를 지나 LLM 으로
* 간다 — 그런 셸은 실측된 바 없고, 임계 마진으로 선제 차단하면 그 마진 크기가 자의적이 되어 정상 미니멀
- * 페이지를 확정 실패로 굳힐 수 있다. 감시는 {@code via=llm} 로그의 html 크기·host 분포로 한다(셸은 본문이
+ * 페이지를 실패로 굳힐 수 있다. 감시는 {@code via=llm} 로그의 html 크기·host 분포로 한다(셸은 본문이
* 극단적으로 작다).
*/
final class LlmInputGate {
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiException.java b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiException.java
index cf65446..8f87400 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiException.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiException.java
@@ -7,8 +7,9 @@
import org.springframework.web.client.RestClientResponseException;
/**
- * Gemini 호출 실패의 계약 예외. 일시/확정 분류를 이 클래스의 정적 팩토리에 모아 두고,
- * GeminiRetry 는 permanent 플래그만 보고 재시도를 판단한다.
+ * Gemini 호출 실패의 계약 예외. 호출자 쪽 분류는 전부 일시(permanent=false)다 — LLM 이 낸 깨진 응답도 받은 문서를
+ * 보고 생긴 결과라 확정으로 닫을 근거가 없다(infra#145). 같은 요청의 즉시 재호출 여부는 별개 축 {@link #retryable()}
+ * 이고, GeminiRetry 와 모델 프로브가 그것만 본다.
*
*
message 는 로그·디버깅용 고정 문구이며 응답 body 에는 code 만 나간다.
*/
@@ -26,8 +27,8 @@ public final class GeminiApiException extends ExtractionException {
*/
private final Integer httpStatus;
- private GeminiApiException(ExtractionErrorCode code, boolean permanent, Integer httpStatus, Throwable cause) {
- super(USER_MESSAGE, code, permanent, cause);
+ private GeminiApiException(ExtractionErrorCode code, Integer httpStatus, Throwable cause) {
+ super(USER_MESSAGE, code, false, cause);
this.httpStatus = httpStatus;
}
@@ -35,17 +36,22 @@ public Integer httpStatus() {
return httpStatus;
}
+ /** Gemini 쪽 일시 장애(5xx·429·408·transport)만 같은 요청을 즉시 다시 보낸다. 깨진 응답·정책 거부는 보내도 LLM 비용만 든다. */
+ public boolean retryable() {
+ return code() == ExtractionErrorCode.LLM_UPSTREAM;
+ }
+
public static GeminiApiException upstreamError(Throwable cause) {
- return new GeminiApiException(ExtractionErrorCode.LLM_UPSTREAM, false, null, cause);
+ return new GeminiApiException(ExtractionErrorCode.LLM_UPSTREAM, null, cause);
}
public static GeminiApiException clientError(Throwable cause) {
- return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, true, null, cause);
+ return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, null, cause);
}
/**
- * 4xx 는 원칙적으로 재시도해도 결과가 같은 확정 실패지만, 429·408 은 다시 보내면 풀릴 수 있어
- * 5xx 와 같은 일시 장애로 가른다.
+ * 4xx 는 같은 요청을 다시 보내도 결과가 같지만, 429·408 은 다시 보내면 풀릴 수 있어 5xx 와 같은
+ * 일시 장애(LLM_UPSTREAM)로 가른다.
*/
public static GeminiApiException fromResponseError(RestClientResponseException e) {
HttpStatusCode status = e.getStatusCode();
@@ -54,20 +60,20 @@ public static GeminiApiException fromResponseError(RestClientResponseException e
|| status.value() == HttpStatus.TOO_MANY_REQUESTS.value()
|| status.value() == HttpStatus.REQUEST_TIMEOUT.value();
ExtractionErrorCode code = retryable ? ExtractionErrorCode.LLM_UPSTREAM : ExtractionErrorCode.LLM_INVALID_RESPONSE;
- return new GeminiApiException(code, !retryable, status.value(), e);
+ return new GeminiApiException(code, status.value(), e);
}
/** 역직렬화 이전에 body 자체가 없는 경우 — transport·인프라 이슈일 가능성이 커 일시 실패로 본다. */
public static GeminiApiException emptyResponse() {
- return new GeminiApiException(ExtractionErrorCode.LLM_UPSTREAM, false, null, null);
+ return new GeminiApiException(ExtractionErrorCode.LLM_UPSTREAM, null, null);
}
public static GeminiApiException parseError(Throwable cause) {
- return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, true, null, cause);
+ return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, null, cause);
}
- /** 스키마는 유효하지만 candidates/parts 가 비어 있는 경우 — safety filter 등 정책적 거부라 재시도가 무의미하다. */
+ /** 스키마는 유효하지만 candidates/parts 가 비어 있는 경우 — safety filter 등 정책적 거부라 즉시 재호출이 무의미하다. */
public static GeminiApiException noTextPart() {
- return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, true, null, null);
+ return new GeminiApiException(ExtractionErrorCode.LLM_INVALID_RESPONSE, null, null);
}
}
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java
index 78364dd..39eccfe 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java
@@ -203,7 +203,7 @@ private Res callWithRetry(Req request, Class resultType, String
.body(GeminiGenerateContentResponse.class);
} catch (RestClientResponseException e) {
// 오류 body 는 Google 이 밝힌 실패 사유(키 정지·API 비활성·쿼터 종류)가 실리는 유일한 자리다.
- // 예외 code 분류로는 사유가 뭉개지고 확정 실패는 상위에서 스택 없이 로깅되므로, body 가 있는 여기서 남긴다.
+ // 예외 code 분류로는 사유가 뭉개지므로, body 가 있는 여기서 남긴다.
log.warn(
"Gemini 응답 오류 - model={} status={} body={}",
model,
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetry.java b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetry.java
index d883c48..a52d937 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetry.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetry.java
@@ -7,7 +7,7 @@
/**
* Gemini 호출의 일시 장애를 지수 백오프로 재시도한다. 재시도 여부 판단을 개별 예외 타입이 아니라
- * GeminiApiException 의 일시/확정 구분에 위임하므로, 분류는 GeminiApiException 한 곳에만 있다.
+ * GeminiApiException 의 retryable 에 위임하므로, 분류는 GeminiApiException 한 곳에만 있다.
*/
@Slf4j
@RequiredArgsConstructor
@@ -23,7 +23,7 @@ public T execute(Supplier block) {
try {
return block.get();
} catch (GeminiApiException e) {
- if (e.permanent() || attempt >= config.maxAttempts()) {
+ if (!e.retryable() || attempt >= config.maxAttempts()) {
throw e;
}
long delayMs = backoffMillis(attempt);
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java
index 7cb92f2..f2e5212 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java
@@ -145,8 +145,8 @@ private PageContent fetchFollowingRedirects(ProductLink link) {
/**
* 상품 경로를 요청했는데 redirect 가 사이트 루트로 보내는 경우. 몰이 진열 중단·일시 상태로 상품 주소를 홈으로
- * 돌리는 패턴이라 홈 본문을 받아 LLM 에 넘기면 확정 실패(NOT_PRODUCT_PAGE)로 닫혀 버린다(packnfold 실측,
- * extractor#82). 본문을 받기 전에 끊어 일시 실패로 남긴다. 루트로 등록된 링크는 판정 대상이 아니다.
+ * 돌리는 패턴이라 홈 본문을 받아 LLM 에 넘기면 상품 아님(NOT_PRODUCT_PAGE)으로 닫혀 버린다(packnfold 실측,
+ * extractor#82). 본문을 받기 전에 끊어 장애 쪽 code 로 남긴다. 루트로 등록된 링크는 판정 대상이 아니다.
* 헤드리스 쪽의 같은 판정은 HeadlessProductLinkExtractor 가 홉 후보 단계에서 한다.
*/
private static boolean landsOnSiteRoot(ProductLink link, ProductLink target) {
@@ -229,7 +229,7 @@ private PageFetchException statusFailure(
/**
* 상품 페이지가 아닌 것이 확실하면 본문을 읽기 전에 끊는다. 헤드리스로 다시 시도할 여지가 없어(영상은 브라우저로
- * 열어도 영상이다) escalatable 축을 가진 PageFetchException 이 아니라 "상품 페이지가 아님" 확정 실패로 낸다.
+ * 열어도 영상이다) escalatable 축을 가진 PageFetchException 이 아니라 "상품 페이지가 아님" 실패로 낸다.
*/
private void rejectBinary(ProductLink current, MediaType contentType) {
if (contentType == null) {
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
index 19f21da..9f7397a 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
@@ -69,21 +69,21 @@ public static PageFetchException unresolvableHost(Throwable cause) {
}
/**
- * 대상 서버가 결정론적 재실패로 보는 5xx(HttpPageFetcher 의 PERMANENT_SERVER_ERRORS)를 준 경우. 우리가 fetch 하는
- * 대형 몰은 사실상 상시 가용이라 대개 진짜 장애가 아니라 봇 방어다 — 확정 실패로 보고 escalatable=true(헤드리스면
- * 뚫릴 수 있다). body 유무로 봇차단/장애를 나누지 않는다: 봇 방어가 body(캡차·차단 페이지)를 실을 수 있어
+ * 대상 서버가 UPSTREAM_ERROR 밖의 5xx(HttpPageFetcher 의 PERMANENT_SERVER_ERRORS)를 준 경우. 우리가 fetch 하는
+ * 대형 몰은 사실상 상시 가용이라 대개 진짜 장애가 아니라 봇 방어다 — 봇 방어는 간헐이라 일시 실패, escalatable=true
+ * (헤드리스면 뚫릴 수 있다). code 이름은 확정이던 때의 잔재다(계약이 additive 라 개명 없음). body 유무로 봇차단/장애를 나누지 않는다: 봇 방어가 body(캡차·차단 페이지)를 실을 수 있어
* 구분이 불확실하다.
*/
public static PageFetchException permanentUpstreamError(Throwable cause) {
- return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.PERMANENT_UPSTREAM, true, cause, true);
+ return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.PERMANENT_UPSTREAM, false, cause, true);
}
/**
- * 4xx (403·404·410·429 등). 입력 URL 문제로 보는 확정 실패이나, 봇 방어가 404("없는 척")·403(차단)·
- * 429(throttle)로 클로킹할 수 있어 escalatable=true — 실제 브라우저면 뚫릴 수 있다.
+ * 4xx (403·404·410·429 등). 봇 방어가 404("없는 척")·403(차단)·429(throttle)로 클로킹할 수 있어 입력 URL
+ * 문제로 단정하지 않는다 — 일시 실패이고 escalatable=true(실제 브라우저면 뚫릴 수 있다).
*/
public static PageFetchException clientError(Throwable cause) {
- return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.FETCH_CLIENT_ERROR, true, cause, true);
+ return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.FETCH_CLIENT_ERROR, false, cause, true);
}
/** 응답 body 가 빈 경우. 일시적일 수 있어 일시 실패이되, 봇이 빈 응답으로 막는 것일 수도 있어 escalatable=true. */
@@ -93,14 +93,14 @@ public static PageFetchException emptyBody() {
/**
* fetch 는 2xx 였지만 본문이 데이터 없는 CSR 셸이라 파싱이 no-data 로 끝난 경우(EmptyShellDetector 판정,
- * cause 가 원래의 파싱 실패). 정적 fetch 는 몇 번을 받아도 같은 셸이라 확정 실패이되, 실제 브라우저는 JS 를
+ * cause 가 원래의 파싱 실패). 셸인지 차단 페이지인지 본문으로는 못 가르므로 일시 실패이고, 실제 브라우저는 JS 를
* 실행해 콘텐츠를 그리므로 escalatable=true — "200 이지만 빈 셸" 이 기존 에스컬레이션 축(fetch 예외)의
* 사각이던 것을 닫는다(카카오 톡딜 실측).
*/
public static PageFetchException emptyShell(Throwable cause) {
// 다른 팩토리와 달리 cause 가 필수다 — 재분류 예외라 원래의 파싱 실패 없이 만들어질 수 없다.
Objects.requireNonNull(cause, "cause");
- return new PageFetchException("해당 링크에서 정보를 가져오지 못했어요.", ExtractionErrorCode.EMPTY_SHELL, true, cause, true);
+ return new PageFetchException("해당 링크에서 정보를 가져오지 못했어요.", ExtractionErrorCode.EMPTY_SHELL, false, cause, true);
}
/**
@@ -114,19 +114,19 @@ public static PageFetchException redirectedToSiteRoot() {
}
/**
- * redirect 가 hop 상한을 넘어 무한·체인 의심. 대상 페이지의 고정된 비정상 상태라 확정 실패.
- * redirect 기반 차단(챌린지로 튕김)일 수 있고 헤드리스는 redirect 를 네이티브로 다루므로 escalatable=true.
+ * redirect 가 hop 상한을 넘어 무한·체인 의심. redirect 기반 차단(챌린지로 튕김)일 수 있어 일시 실패이고,
+ * 헤드리스는 redirect 를 네이티브로 다루므로 escalatable=true.
*/
public static PageFetchException tooManyRedirects() {
- return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.TOO_MANY_REDIRECTS, true, null, true);
+ return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.TOO_MANY_REDIRECTS, false, null, true);
}
/**
- * 대상 서버가 3xx 를 주면서 Location 이 없거나 깨졌거나 https 가 아닌 위치를 준 redirect. 확정 실패,
+ * 대상 서버가 3xx 를 주면서 Location 이 없거나 깨졌거나 https 가 아닌 위치를 준 redirect. 일시 실패,
* escalatable=true — 브라우저는 http 위치도 따라간다.
*/
public static PageFetchException malformedRedirect(Throwable cause) {
- return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.MALFORMED_REDIRECT, true, cause, true);
+ return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.MALFORMED_REDIRECT, false, cause, true);
}
/**
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java
index 0549290..c357366 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java
@@ -462,7 +462,7 @@ private StructuredExtraction toResult(ProductLink link, String name, String imag
snapshot = ProductSnapshot.fromExtracted(link, name, imageUrl, price, currency);
} catch (ProductSnapshotException e) {
// 구조화 경로는 fromExtracted 의 범위 위반을 흡수해 INVALID_VALUE(→LLM fallback)로 보고한다
- // (LLM 경로는 같은 예외를 그대로 흘려 확정 실패로 떨어뜨린다 — 같은 검증, 실패 표현만 다름).
+ // (LLM 경로는 같은 예외를 그대로 흘려 실패로 떨어뜨린다 — 같은 검증, 실패 표현만 다름).
return StructuredExtraction.Miss.INVALID_VALUE;
}
if (snapshot.name() == null) {
diff --git a/src/main/java/com/depromeet/piki/extractor/probe/ModelProbeService.java b/src/main/java/com/depromeet/piki/extractor/probe/ModelProbeService.java
index f2af53c..95e03ae 100644
--- a/src/main/java/com/depromeet/piki/extractor/probe/ModelProbeService.java
+++ b/src/main/java/com/depromeet/piki/extractor/probe/ModelProbeService.java
@@ -74,7 +74,7 @@ private void callOnce(String model, ProbeTarget target) {
}
/**
- * 404 만 "그런 모델이 없다"로 가르고, 나머지 확정 실패는 전부 "이 경로에서 못 쓴다"로 묶는다 — 요청 스키마
+ * 404 만 "그런 모델이 없다"로 가르고, 나머지 재호출 무의미 실패는 전부 "이 경로에서 못 쓴다"로 묶는다 — 요청 스키마
* 비호환(400)이든 200 인데 스키마를 못 맞춘 응답이든, 운영자가 할 일은 같은 하나(다른 모델을 고른다)라
* 사유를 더 쪼개도 화면에서 쓸 데가 없다.
*
@@ -82,7 +82,7 @@ private void callOnce(String model, ProbeTarget target) {
* 흔들린 사이에 멀쩡한 모델이 "쓸 수 없는 모델"로 판정된다.
*/
private ExtractionException translate(GeminiApiException e, String model, ProbeTarget target) {
- if (!e.permanent()) {
+ if (e.retryable()) {
log.warn("model probe transient target={} model={}", target, model);
return e;
}
diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
index b76bc84..b81246b 100644
--- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
@@ -150,7 +150,7 @@ void headlessAllowedFieldIsAcceptedOnTheWire() throws Exception {
}
@Test
- @DisplayName("LLM 이 상품 페이지가 아니라고 판정하면 422 NOT_PRODUCT_PAGE 를 반환한다")
+ @DisplayName("LLM 이 상품 페이지가 아니라고 판정하면 502 NOT_PRODUCT_PAGE 를 반환한다 (호출자가 재시도)")
void notProductPage() throws Exception {
stubGeminiClient.reset();
// 본문 텍스트가 충분한 진짜 콘텐츠 페이지 — CSR 셸 재분류(EMPTY_SHELL)에 걸리지 않고 no-data 원형이 나간다.
@@ -161,12 +161,12 @@ void notProductPage() throws Exception {
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://blog.example.com/post/3")))
- .andExpect(status().isUnprocessableEntity())
+ .andExpect(status().isBadGateway())
.andExpect(jsonPath("$.code").value("NOT_PRODUCT_PAGE"));
}
@Test
- @DisplayName("CSR 셸은 LLM 호출 없이 422 EMPTY_SHELL 로 재분류된다 (헤드리스 꺼진 구성의 표면 계약)")
+ @DisplayName("CSR 셸은 LLM 호출 없이 502 EMPTY_SHELL 로 재분류된다 (헤드리스 꺼진 구성의 표면 계약)")
void emptyShellReclassified() throws Exception {
stubGeminiClient.reset();
// 카카오 톡딜 store.kakao.com 실측 축약형 — script 뿐인 SPA 셸. 게이트(LlmInputGate)가 LLM 호출 전에
@@ -178,7 +178,7 @@ void emptyShellReclassified() throws Exception {
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://store.kakao.com/kgcmall/products/445653929")))
- .andExpect(status().isUnprocessableEntity())
+ .andExpect(status().isBadGateway())
.andExpect(jsonPath("$.code").value("EMPTY_SHELL"));
// 빈 입력의 LLM 은 환각을 만든다 — 셸에서 LLM 이 불리지 않는 것 자체가 계약이다.
@@ -205,22 +205,22 @@ void incompleteExtraction() throws Exception {
}
@Test
- @DisplayName("추출이 값을 하나도 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다")
+ @DisplayName("추출이 값을 하나도 못 채우면 502 UNTRUSTWORTHY_VALUE 를 반환한다 (호출자가 재시도)")
void noExtractedValue() throws Exception {
stubGeminiClient.reset();
stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML);
- // 상품 페이지라고는 하는데 아무 값도 못 뽑은 경우 — 사용자에게 무엇을 채우라 할 근거조차 없어 확정 실패로 닫는다.
+ // 상품 페이지라고는 하는데 아무 값도 못 뽑은 경우 — 사용자에게 무엇을 채우라 할 근거조차 없어 실패로 닫는다.
stubGeminiClient.build = request -> new GeminiExtractionResult(true, null, null, null, null);
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://shop.example.com/p/empty")))
- .andExpect(status().isUnprocessableEntity())
+ .andExpect(status().isBadGateway())
.andExpect(jsonPath("$.code").value("UNTRUSTWORTHY_VALUE"));
}
@Test
- @DisplayName("대상 몰이 4xx 로 막으면 422 FETCH_CLIENT_ERROR 를 반환한다")
+ @DisplayName("대상 몰이 4xx 로 막으면 502 FETCH_CLIENT_ERROR 를 반환한다 (호출자가 재시도)")
void fetchClientError() throws Exception {
stubGeminiClient.reset();
stubPageFetcher.build = link -> {
@@ -230,7 +230,7 @@ void fetchClientError() throws Exception {
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://blocked.example.com/p/5")))
- .andExpect(status().isUnprocessableEntity())
+ .andExpect(status().isBadGateway())
.andExpect(jsonPath("$.code").value("FETCH_CLIENT_ERROR"));
}
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
index 075a22d..1ed40ab 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
@@ -1,6 +1,7 @@
package com.depromeet.piki.extractor.extraction;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -61,7 +62,7 @@ void reclassifiesNoDataOnEmptyShell() {
assertEquals(ExtractionErrorCode.EMPTY_SHELL, e.code());
assertTrue(e.escalatable());
- assertTrue(e.permanent());
+ assertFalse(e.permanent());
// 원래의 파싱 실패를 cause 로 보존한다 — 에스컬레이션 후에도 최초 사유를 로그에서 추적할 수 있어야 한다.
assertInstanceOf(ProductSnapshotException.class, e.getCause());
assertEquals(0, stubGemini.invocations());
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiExceptionTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiExceptionTest.java
index a2f03af..4caa248 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiExceptionTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiApiExceptionTest.java
@@ -17,47 +17,48 @@ class GeminiApiExceptionTest {
@ParameterizedTest
@ValueSource(ints = {500, 502, 503, 504})
- @DisplayName("5xx 응답은 재시도 대상(permanent=false·LLM_UPSTREAM)으로 분류된다")
+ @DisplayName("5xx 응답은 즉시 재호출 대상(retryable·LLM_UPSTREAM)으로 분류된다")
void serverErrorsAreRetryable(int status) {
HttpServerErrorException responseError = new HttpServerErrorException(HttpStatus.valueOf(status));
GeminiApiException exception = GeminiApiException.fromResponseError(responseError);
- assertFalse(exception.permanent());
+ assertTrue(exception.retryable());
assertEquals(ExtractionErrorCode.LLM_UPSTREAM, exception.code());
}
@Test
- @DisplayName("429 Too Many Requests 는 재시도 대상(permanent=false·LLM_UPSTREAM)으로 분류된다")
+ @DisplayName("429 Too Many Requests 는 즉시 재호출 대상(retryable·LLM_UPSTREAM)으로 분류된다")
void tooManyRequestsIsRetryable() {
HttpClientErrorException responseError = new HttpClientErrorException(HttpStatus.TOO_MANY_REQUESTS);
GeminiApiException exception = GeminiApiException.fromResponseError(responseError);
- assertFalse(exception.permanent());
+ assertTrue(exception.retryable());
assertEquals(ExtractionErrorCode.LLM_UPSTREAM, exception.code());
}
@Test
- @DisplayName("408 Request Timeout 은 재시도 대상(permanent=false·LLM_UPSTREAM)으로 분류된다")
+ @DisplayName("408 Request Timeout 은 즉시 재호출 대상(retryable·LLM_UPSTREAM)으로 분류된다")
void requestTimeoutIsRetryable() {
HttpClientErrorException responseError = new HttpClientErrorException(HttpStatus.REQUEST_TIMEOUT);
GeminiApiException exception = GeminiApiException.fromResponseError(responseError);
- assertFalse(exception.permanent());
+ assertTrue(exception.retryable());
assertEquals(ExtractionErrorCode.LLM_UPSTREAM, exception.code());
}
@ParameterizedTest
@ValueSource(ints = {400, 401, 403, 404})
- @DisplayName("429·408 을 제외한 4xx 응답은 확정 실패(permanent=true·LLM_INVALID_RESPONSE)로 분류된다")
- void otherClientErrorsArePermanent(int status) {
+ @DisplayName("429·408 을 제외한 4xx 응답은 재호출 없는 일시 실패(LLM_INVALID_RESPONSE)로 분류된다")
+ void otherClientErrorsAreNotRetried(int status) {
HttpClientErrorException responseError = new HttpClientErrorException(HttpStatus.valueOf(status));
GeminiApiException exception = GeminiApiException.fromResponseError(responseError);
- assertTrue(exception.permanent());
+ assertFalse(exception.retryable());
+ assertFalse(exception.permanent());
assertEquals(ExtractionErrorCode.LLM_INVALID_RESPONSE, exception.code());
}
}
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetryTest.java
index 6f35b55..bb8a21d 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetryTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiRetryTest.java
@@ -73,8 +73,8 @@ void recoversAfterRetry() {
}
@Test
- @DisplayName("확정 실패 예외는 재시도하지 않고 즉시 던진다")
- void permanentFailureIsNotRetried() {
+ @DisplayName("재호출 무의미 예외(깨진 응답)는 재시도하지 않고 즉시 던진다")
+ void nonRetryableFailureIsNotRetried() {
AtomicInteger calls = new AtomicInteger();
assertThrows(GeminiApiException.class, () -> retry.execute(() -> {
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherServerErrorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherServerErrorTest.java
index 8adbc3c..90e3c02 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherServerErrorTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherServerErrorTest.java
@@ -39,9 +39,9 @@ private HttpPageFetcher fetcherWith(Consumer configure) {
}
@Test
- @DisplayName("500·501 은 영구 실패로 보아 재시도하지 않는다")
- void status500And501ArePermanent() {
- // KREAM 처럼 봇 차단을 body 없는 500 으로 응답하는 케이스. 재시도해도 결정론적으로 재실패한다.
+ @DisplayName("500·501 은 PERMANENT_UPSTREAM 이되 일시 실패다")
+ void status500And501AreTransientUpstream() {
+ // KREAM 처럼 봇 차단을 body 없는 500 으로 응답하는 케이스. 봇 차단은 간헐이라 호출자 재시도 대상이다.
for (HttpStatus status : List.of(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.NOT_IMPLEMENTED)) {
HttpPageFetcher fetcher =
fetcherWith(server -> server.expect(requestTo("https://shop.example.com/p")).andRespond(withStatus(status)));
@@ -51,7 +51,7 @@ void status500And501ArePermanent() {
() -> fetcher.fetch(ProductLink.parse("https://shop.example.com/p")));
assertEquals(ExtractionErrorCode.PERMANENT_UPSTREAM, ex.code(), status + " 는 PERMANENT_UPSTREAM 이어야 함");
- assertTrue(ex.permanent(), status + " 는 확정 실패(재시도 불가)여야 함");
+ assertFalse(ex.permanent(), status + " 는 일시 실패(호출자 재시도)여야 함");
assertTrue(ex.escalatable(), status + "(no-body 봇차단)는 헤드리스로 escalate 대상이어야 함");
}
}
@@ -71,7 +71,7 @@ void status500And501AreEscalatableRegardlessOfBody() {
() -> fetcher.fetch(ProductLink.parse("https://shop.example.com/p")));
assertEquals(ExtractionErrorCode.PERMANENT_UPSTREAM, ex.code(), status + " body 있어도 PERMANENT_UPSTREAM");
- assertTrue(ex.permanent(), status + " body 있어도 확정 실패여야 함");
+ assertFalse(ex.permanent(), status + " body 있어도 일시 실패여야 함");
assertTrue(ex.escalatable(), status + " body 있어도 escalate 대상이어야 함(봇 방어가 body 를 실을 수 있음)");
}
}
@@ -96,8 +96,8 @@ void status502To504AreRetryableAndEscalatable() {
}
@Test
- @DisplayName("4xx 는 확정 실패이되 봇 클로킹 가능성으로 escalatable 이다")
- void status4xxIsPermanentButEscalatable() {
+ @DisplayName("4xx 는 봇 클로킹 가능성으로 일시 실패이고 escalatable 이다")
+ void status4xxIsTransientAndEscalatable() {
// 봇 방어가 404("없는 척")·403(차단)·429(throttle)로 클로킹할 수 있어, 4xx 는 헤드리스로 뚫릴 후보로 본다(무조건 폴백).
for (HttpStatus status : List.of(HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.GONE, HttpStatus.TOO_MANY_REQUESTS)) {
HttpPageFetcher fetcher =
@@ -108,7 +108,7 @@ void status4xxIsPermanentButEscalatable() {
() -> fetcher.fetch(ProductLink.parse("https://shop.example.com/p")));
assertEquals(ExtractionErrorCode.FETCH_CLIENT_ERROR, ex.code(), status + " 는 FETCH_CLIENT_ERROR 여야 함");
- assertTrue(ex.permanent(), status + " 는 확정 실패여야 함");
+ assertFalse(ex.permanent(), status + " 는 일시 실패여야 함");
assertTrue(ex.escalatable(), status + " 는 헤드리스로 escalate 대상이어야 함");
}
}
From 10f5eeadc6aec0e9b15856aba38d5aa299577986 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:33:53 +0900
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9D=BC=EC=8B=9C=20=EC=8B=A4?=
=?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=EC=9D=98=20=EC=8A=A4=ED=83=9D?=
=?UTF-8?q?=EC=9D=84=20cause=20=EC=9E=88=EC=9D=84=20=EB=95=8C=EB=A1=9C=20?=
=?UTF-8?q?=ED=95=9C=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 핸들러가 cause 없는 일시 실패는 스택 없이 warn
- 원인: 받은 페이지 기반 판정(상품 아님 등)이 일시로 바뀌며 전부 warn+스택이 됨
- 원인: cause 없는 예외의 스택은 던진 자리만 가리켜 정보 0
- 프로브 테스트의 permanent 단언을 retryable 로 교체
- 원인: Gemini 예외는 전부 permanent=false 라 단언이 공허함
- 카탈로그 테스트 주석의 422 전제 정정
---
.../piki/extractor/api/ExtractionExceptionHandler.java | 7 +++++--
.../common/exception/ExtractionErrorCodeCatalogTest.java | 2 +-
.../piki/extractor/probe/ModelProbeServiceTest.java | 2 +-
3 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionExceptionHandler.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionExceptionHandler.java
index 8febc54..cba15bf 100644
--- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionExceptionHandler.java
+++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionExceptionHandler.java
@@ -21,11 +21,14 @@ public class ExtractionExceptionHandler {
@ExceptionHandler(ExtractionException.class)
public ResponseEntity handleExtraction(ExtractionException e) {
- // 확정 실패는 계약상 정상 결과라 info, 일시 실패는 외부 의존성 문제라 warn.
+ // 확정 실패는 링크 자체의 사실이라 info. 일시 실패는 warn 이되, 받은 페이지를 보고 내린 판정(상품 아님 등)은
+ // cause 가 없어 스택이 던진 자리만 가리키므로 cause 가 있을 때만 스택을 싣는다.
if (e.permanent()) {
log.info("extraction failed permanently code={} message={}", e.code(), e.getMessage());
- } else {
+ } else if (e.getCause() != null) {
log.warn("extraction failed transiently code={} message={}", e.code(), e.getMessage(), e);
+ } else {
+ log.warn("extraction failed transiently code={} message={}", e.code(), e.getMessage());
}
HttpStatus status = e.permanent() ? HttpStatus.UNPROCESSABLE_ENTITY : HttpStatus.BAD_GATEWAY;
// body 에 message 같은 내부 정보는 싣지 않는다 — 디버깅 컨텍스트는 위 로그가 책임진다.
diff --git a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java
index d66af7a..6c454ad 100644
--- a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java
@@ -53,7 +53,7 @@ class ExtractionErrorCodeCatalogTest {
@Test
@DisplayName("ExtractionErrorCode 상수 집합은 계약 enum 의 code 집합과 정확히 일치한다")
void enumMatchesContract() {
- // 핸들러가 도메인 code 이름으로 계약 enum 을 찾으므로, 목록이 어긋나면 그 code 의 422 가 500 으로 샌다.
+ // 핸들러가 도메인 code 이름으로 계약 enum 을 찾으므로, 목록이 어긋나면 그 code 의 실패 응답이 500 으로 샌다.
Set contractCodes = Arrays.stream(com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode.values())
.filter(value -> value != com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode.UNRECOGNIZED)
.filter(value -> value.getNumber() != 0)
diff --git a/src/test/java/com/depromeet/piki/extractor/probe/ModelProbeServiceTest.java b/src/test/java/com/depromeet/piki/extractor/probe/ModelProbeServiceTest.java
index 1015a5c..7e92428 100644
--- a/src/test/java/com/depromeet/piki/extractor/probe/ModelProbeServiceTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/probe/ModelProbeServiceTest.java
@@ -117,6 +117,6 @@ void propagatesTransientFailure() {
GeminiApiException e =
assertThrows(GeminiApiException.class, () -> serviceThat(stub).probe(MODEL, ProbeTarget.LINK));
- assertEquals(false, e.permanent());
+ assertEquals(true, e.retryable());
}
}