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/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/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/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 대상이어야 함"); } } 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()); } }