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 @@ -21,11 +21,14 @@ public class ExtractionExceptionHandler {

@ExceptionHandler(ExtractionException.class)
public ResponseEntity<ExtractionFailure> 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 같은 내부 정보는 싣지 않는다 — 디버깅 컨텍스트는 위 로그가 책임진다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 는
* 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,7 +50,7 @@ public boolean missingReadyField() {
* 추출값을 하나도 못 얻었는가 — 호출자에게 내려보낼 것도, 사용자에게 "무엇을 채우라" 할 근거도 없는 상태다.
*
* <p>부분값(일부만 채움)은 호출자가 INCOMPLETE 로 수용해 사용자가 나머지를 채우므로(TeamPiKi/core#944)
* 성공으로 내려보내고, 이 판정이 참일 때만 확정 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
* 성공으로 내려보내고, 이 판정이 참일 때만 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
* "건졌다"의 근거가 되지 못하므로 세지 않는다.
*/
public boolean hasNoExtractedValue() {
Expand Down Expand Up @@ -84,7 +84,7 @@ public String missingFieldNames() {
* imageUrl 을 https 로 좁히는 것은 클라이언트가 {@code <img src>} 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
* 다만 좁히는 방식은 스킴에 따라 다르다 — 자세한 근거는 normalizeImageUrl 참조.
* <p>범위를 벗어난 값은 {@link ProductSnapshotException#untrustworthyValue()} 로 막고, 그 뒤 처리는 호출부가 고른다:
* 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 확정 실패로 떨어뜨린다.
* 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 실패로 떨어뜨린다.
* 같은 검증, 실패 표현만 다르다.
*/
public static ProductSnapshot fromExtracted(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@
* 일이라 허락을 전제하지 않는다. authorized 를 쓰는 곳은 렌더 서비스뿐이고,
* 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다).
*
* <p>에스컬레이션 축(plain 확정 → headless)은 호출자 파싱 작업 큐의 재시도 축(일시 오류 → 같은 plain 재시도)과
* 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서
* 여기서 별도로 판정한다.
* <p>에스컬레이션 축(plain 실패 → headless)은 호출자 파싱 작업 큐의 재시도 축(같은 plain 재시도)과 직교한다.
* 호출자 재시도는 브라우저를 열지 않으므로, 브라우저가 필요한 건은 한 시도 안에서 여기서 승격한다.
*/
@Slf4j
@RequiredArgsConstructor
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -114,7 +113,7 @@ private Counter escalationCounter(String outcome, String category) {
}

/**
* headless 결과의 성공 판정. 예외 없이 반환됐어도 READY 필수 필드가 비면 응답 경계에서 확정 실패로 닫히는
* headless 결과의 성공 판정. 예외 없이 반환됐어도 READY 필수 필드가 비면 응답 경계에서 실패로 닫히는
* 결과다 — 그걸 success 로 세면 구제 성공률(escalation)·직행 성공률(headless_first)이 실제보다 부푼다.
* outcome 은 예외 여부가 아니라 "요청을 살렸는가"를 센다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* <p>차단 신호는 후보를 거르지 않는다. 403 뒤에 온전한 상품 JSON-LD 가 실려 오기도 하므로(봇 방어의 위장 status)
* 구조화 데이터는 그대로 쓰고, 차단으로 보이는 문서는 LLM 후보에서만 뺀다. 아무것도 못 뽑았을 때 실패 코드를
* 차단과 장애로 가르는 데만 쓴다. 상품 경로가 사이트 루트에 착지한 홉도 같은 취급이다 — 홈 피드를 LLM 에 보내면
* 확정 실패로 닫혀 버리므로 후보에서 빼고, 그것뿐이면 일시 실패로 남긴다(HttpPageFetcher 의 redirect 판정과 짝).
* 상품 아님으로 닫혀 버리므로 후보에서 빼고, 그것뿐이면 장애 쪽 code 로 남긴다(HttpPageFetcher 의 redirect 판정과 짝).
*/
@Slf4j
@RequiredArgsConstructor
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/**
* HTML → ProductSnapshot 의 공통 후반부: 구조화 데이터(JSON-LD/OpenGraph) 우선, 미달이면 같은 HTML 을 Gemini 로
* 넘긴다(재fetch 없음). 단 LLM 이 볼 게 아무것도 없는 문서({@link LlmInputGate})는 넘기지 않고 확정 실패로
* 넘긴다(재fetch 없음). 단 LLM 이 볼 게 아무것도 없는 문서({@link LlmInputGate})는 넘기지 않고 실패로
* 끊는다 — 빈 입력의 LLM 은 실존하지 않는 상품을 지어내고, 그 값은 형식이 유효해 하류 검증을 전부 통과한다.
* HTML 을 어디서 얻었는지(정적 fetch, 헤드리스 렌더)와 무관한 파싱 파이프라인이라 두 전략이 공유한다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
* 입력이 비었음을 아는 이 지점이 유일한 차단 기회다.
*
* <p>{@link EmptyShellDetector}(에스컬레이션 축)와 판정을 분리하는 이유는 오탐 비용의 비대칭이다 — 그쪽
* 오탐은 헤드리스 1회 낭비(fail-open)로 그치지만, 이 게이트의 오탐은 확정 실패(422)로 굳는다. 그래서 길이
* 오탐은 헤드리스 1회 낭비(fail-open)로 그치지만, 이 게이트의 오탐은 재시도를 거쳐도 같은 실패로 굳는다. 그래서 길이
* 임계("짧다") 같은 판단을 두지 않고 사실만 본다: 가시 텍스트가 전혀 없고, 데이터 script(JSON-LD·data
* island — {@link GeminiHtmlExtractor} sanitize 가 보존하는 것)도 없다. 환각 사고 케이스는 렌더 후에도
* 0자였다. jsoup 의 {@code text()} 는 script 내용을 세지 않으므로 데이터 script 유무는 별도 조건이 진다.
*
* <p>수용한 잔존 위험: 가시 텍스트가 몇십 자(접근성 링크·푸터 보일러플레이트)뿐인 셸은 게이트를 지나 LLM 으로
* 간다 — 그런 셸은 실측된 바 없고, 임계 마진으로 선제 차단하면 그 마진 크기가 자의적이 되어 정상 미니멀
* 페이지를 확정 실패로 굳힐 수 있다. 감시는 {@code via=llm} 로그의 html 크기·host 분포로 한다(셸은 본문이
* 페이지를 실패로 굳힐 수 있다. 감시는 {@code via=llm} 로그의 html 크기·host 분포로 한다(셸은 본문이
* 극단적으로 작다).
*/
final class LlmInputGate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import org.springframework.web.client.RestClientResponseException;

/**
* Gemini 호출 실패의 계약 예외. 일시/확정 분류를 이 클래스의 정적 팩토리에 모아 두고,
* GeminiRetry 는 permanent 플래그만 보고 재시도를 판단한다.
* Gemini 호출 실패의 계약 예외. 호출자 쪽 분류는 전부 일시(permanent=false)다 — LLM 이 낸 깨진 응답도 받은 문서를
* 보고 생긴 결과라 확정으로 닫을 근거가 없다(infra#145). 같은 요청의 즉시 재호출 여부는 별개 축 {@link #retryable()}
* 이고, GeminiRetry 와 모델 프로브가 그것만 본다.
*
* <p>message 는 로그·디버깅용 고정 문구이며 응답 body 에는 code 만 나간다.
*/
Expand All @@ -26,26 +27,31 @@ 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;
}

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();
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ private <Req, Res> Res callWithRetry(Req request, Class<Res> resultType, String
.body(GeminiGenerateContentResponse.class);
} catch (RestClientResponseException e) {
// 오류 body 는 Google 이 밝힌 실패 사유(키 정지·API 비활성·쿼터 종류)가 실리는 유일한 자리다.
// 예외 code 분류로는 사유가 뭉개지고 확정 실패는 상위에서 스택 없이 로깅되므로, body 가 있는 여기서 남긴다.
// 예외 code 분류로는 사유가 뭉개지므로, body 가 있는 여기서 남긴다.
log.warn(
"Gemini 응답 오류 - model={} status={} body={}",
model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

/**
* Gemini 호출의 일시 장애를 지수 백오프로 재시도한다. 재시도 여부 판단을 개별 예외 타입이 아니라
* GeminiApiException 의 일시/확정 구분에 위임하므로, 분류는 GeminiApiException 한 곳에만 있다.
* GeminiApiException 의 retryable 에 위임하므로, 분류는 GeminiApiException 한 곳에만 있다.
*/
@Slf4j
@RequiredArgsConstructor
Expand All @@ -23,7 +23,7 @@ public <T> T execute(Supplier<T> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -229,7 +229,7 @@ private PageFetchException statusFailure(

/**
* 상품 페이지가 아닌 것이 확실하면 본문을 읽기 전에 끊는다. 헤드리스로 다시 시도할 여지가 없어(영상은 브라우저로
* 열어도 영상이다) escalatable 축을 가진 PageFetchException 이 아니라 "상품 페이지가 아님" 확정 실패로 낸다.
* 열어도 영상이다) escalatable 축을 가진 PageFetchException 이 아니라 "상품 페이지가 아님" 실패로 낸다.
*/
private void rejectBinary(ProductLink current, MediaType contentType) {
if (contentType == null) {
Expand Down
Loading
Loading