From ffc89538f989d9daac80ce8e3871d3113f254221 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: Wed, 12 Aug 2026 15:52:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=97=88=EA=B0=80=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EB=8C=80=EC=83=81=EC=97=90=EB=8A=94=20=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=A5=BC=20=EC=97=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청에 headlessAllowed 를 받아, false 면 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)를 한 가드로 전부 닫고 plain 만 돈다. product.extract.headless.enabled 스위치와 AND - 둘 다 서야 헤드리스가 열린다. - 누락·null 은 false(허가 없음)로 정규화하는 fail-safe. 이 필드를 모르는 구버전 호출자의 요청은 헤드리스를 열지 않아, 배포 순서와 무관하게 안전한 쪽으로만 어긋난다. - headlessFirst 도 headlessAllowed 가 false 면 함께 무시된다(허가가 라우팅보다 앞선다). - 계약 문서(api-contract.md)와 컨트롤러 원장 로그에 headlessAllowed 를 반영. 허가는 성능 최적화가 아니라 계약이다. --- docs/api-contract.md | 8 +- .../extractor/api/ExtractionController.java | 12 +- .../extractor/api/LinkExtractionRequest.java | 10 +- .../FallbackProductLinkExtractor.java | 13 ++- .../extraction/ProductLinkExtractor.java | 11 +- .../api/ExtractionLinkIntegrationTest.java | 15 +++ .../api/LinkExtractionRequestTest.java | 38 +++++++ .../FallbackProductLinkExtractorTest.java | 107 +++++++++++++++--- 8 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java diff --git a/docs/api-contract.md b/docs/api-contract.md index 130054b..ece4c45 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -9,6 +9,7 @@ - Extractor 는 **무상태**다. DB 없음, 호출 간 상태 없음. 같은 요청이 중복 도착해도 상태 오염이 없다(중복의 대가는 LLM 비용 한 번뿐). Extractor 에 상태를 넣고 싶어지면 설계 경고 신호다. - 재시도·내구성·상태 전이는 전부 호출자(core 의 item_snapshots outbox)의 책임이다. Extractor 는 "단건 시도 1회"에만 답한다. - 에스컬레이션(plain fetch → headless 브라우저)은 Extractor 내부 관심사다. 응답 계약에 드러나지 않는다 — 호출자는 어떤 fetch 전략이 쓰였는지 모른다. 단 "어느 플랫폼을 처음부터 헤드리스로 보낼지"의 **정책**은 호출자(DB·백오피스)가 주인이라, 요청 필드 `headlessFirst` 로 힌트만 받는다(§2) — 무상태 불변식을 지키는 선에서의 유일한 정책 수용 지점이다. +- **헤드리스는 허가받은 대상에만 쓴다.** 허가 여부의 주인도 호출자라, 요청 필드 `headlessAllowed` 로 받는다(§2). Extractor 는 허가 대상을 알지 못하고 판정도 하지 않으며(무상태), 허가가 없으면 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)를 전부 닫고 plain 만 돈다. 이것은 성능 최적화가 아니라 계약이다. ## 1. 응답 3갈래 (전이 규약) @@ -28,11 +29,12 @@ 요청: ```json -{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false, "model": "gemini-3.1-flash-lite" } +{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false, "headlessAllowed": false, "model": "gemini-3.1-flash-lite" } ``` - `url` (필수): https 스킴의 상품 페이지 URL. 형식·스킴·미지원 플랫폼의 동기 검증은 호출자(core 등록 경계)가 이미 끝냈다는 전제이나, Extractor 도 자기 경계에서 방어 검증한다(다층 방어). -- `headlessFirst` (선택, 기본 false — additive, 이관 7단계): 호출자의 플랫폼 라우팅 정책(`HEADLESS_FIRST`, DB·백오피스 동적 설정) 힌트. true 면 plain(정적 fetch)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다. 정책의 단일 진실은 호출자 DB 에 있고 무상태인 Extractor 는 요청 단위로만 받는다. Extractor 의 `product.extract.headless.enabled` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선). +- `headlessFirst` (선택, 기본 false — additive, 이관 7단계): 호출자의 플랫폼 라우팅 정책(`HEADLESS_FIRST`, DB·백오피스 동적 설정) 힌트. true 면 plain(정적 fetch)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다. 정책의 단일 진실은 호출자 DB 에 있고 무상태인 Extractor 는 요청 단위로만 받는다. Extractor 의 `product.extract.headless.enabled` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선). `headlessAllowed` 가 false 여도 함께 무시된다 — 허가가 라우팅보다 앞선다. +- `headlessAllowed` (선택, 기본 false — additive): 이 대상에 헤드리스 브라우저를 써도 되는지에 대한 호출자의 허가. false 면 Extractor 는 어떤 경로로도 헤드리스를 타지 않는다 — 직행(`headlessFirst`)·차단 승격·불완전 승격 셋 다 닫히고 plain 만 돈다. **누락·null 은 false(허가 없음)로 정규화되는 fail-safe** 라, 이 필드를 모르는 구버전 호출자의 요청은 헤드리스를 열지 않는다(배포 순서 무관, 안전한 쪽으로만 어긋난다). 허가 대상의 단일 진실은 호출자 쪽에 있고 Extractor 는 판정하지 않는다(무상태). `product.extract.headless.enabled` 는 그 위에 남는 Extractor 쪽 운영 비상 차단이다 — 둘 다 서야 헤드리스가 열린다. - `model` (선택, additive — core#875): 이 요청의 LLM 추출에 쓸 모델. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 DB(백오피스)에 있고 Extractor 는 요청 단위로만 받는다. **요청 단위로 받는 이유**: Extractor 박스 한 대를 여러 환경이 공유하므로, 모델을 Extractor 환경변수로 잡으면 dev 에서 바꾼 것이 prod 파싱까지 덮는다. 생략·null·빈 문자열이면 Extractor 의 기본 모델(`GeminiProperties.DEFAULT_MODEL`)을 쓴다 — 구버전 호출자의 요청이 그대로 동작하므로 배포 순서 무관. - **지정 모델이 404 면 기본 모델로 대체하고 추출을 이어간다.** 등록 당시 유효했던 모델이 폐기돼 사라지는 경우가 있고, 그때 파싱 전체가 죽는 것보다 기본 모델로 이어가는 편이 낫다(가용성 우선). 대체가 일어나도 응답 모양은 같으며, 발생 사실은 Extractor 의 warn 로그와 `gemini.model.fallback` 카운터에만 남는다. **400·5xx·timeout 은 대체하지 않는다** — 400 은 요청 body 쪽 결함일 수 있어 대체로 덮으면 버그가 묻히고, 나머지는 모델을 바꾼다고 풀리는 실패가 아니다. - 헤더 `X-Correlation-Id` (선택): 호출자의 item_snapshot id. 로그·trace 상관용이며 동작에 영향 없다. @@ -69,7 +71,7 @@ | `TOO_MANY_REDIRECTS` | `PageFetchException.tooManyRedirects` | | `MALFORMED_REDIRECT` | `PageFetchException.malformedRedirect` | | `PERMANENT_UPSTREAM` | `PageFetchException.permanentUpstreamError` — 대상 500/501 (봇 차단 추정) | -| `EMPTY_SHELL` | `PageFetchException.emptyShell` — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜진 구성에선 헤드리스 결과가 대신 응답된다 | +| `EMPTY_SHELL` | `PageFetchException.emptyShell` — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜지고 `headlessAllowed` 허가가 실린 요청에선 헤드리스 결과가 대신 응답된다 | | `NO_EXTRACTABLE_CONTENT` | `ProductSnapshotException.noExtractableContent` — 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정(빈 셸 환각 차단). plain 경로는 EMPTY_SHELL 재분류가 선행하므로 사실상 헤드리스 렌더 결과까지 셸일 때 나온다 | | `LLM_INVALID_RESPONSE` | `GeminiApiException` clientError/parseError/noTextPart — 재시도 무의미한 LLM 실패 | | `INVALID_URL` | url 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) | diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java index 244ed3e..18040bc 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java @@ -34,14 +34,22 @@ public ExtractionResponse extractLink( ProductLink link = ProductLink.parse(request.url()); // model 은 호출자가 백오피스에서 지정한 값이라 원장에 남긴다 — 추출 품질이 흔들릴 때 "그때 어느 모델이었나"를 // 되짚는 유일한 근거다(자유 문자열이라 메트릭 라벨로는 못 쓴다). + // headlessAllowed 도 원장에 남긴다 — 허가 없는 대상이 브라우저로 갔는지(또는 허가가 왜 안 왔는지)를 + // 사후에 되짚을 수 있는 유일한 근거다. log.info( - "extract request correlationId={} headlessFirst={} model={} url={}", + "extract request correlationId={} headlessFirst={} headlessAllowed={} model={} url={}", correlationId, request.headlessFirst(), + request.headlessAllowed(), request.model(), link.safeLogString() ); - ProductSnapshot snapshot = productLinkExtractor.extract(link, request.headlessFirst(), request.model()); + ProductSnapshot snapshot = productLinkExtractor.extract( + link, + request.headlessFirst(), + request.headlessAllowed(), + request.model() + ); return ExtractionResponse.from(snapshot); } diff --git a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java index dab3a76..e79d63c 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java @@ -6,16 +6,20 @@ *
url 형식 검증을 Bean Validation 이 아니라 {@code ProductLink.parse} 에 맡긴다 — blank·형식·스킴 * 위반이 전부 계약 코드 INVALID_URL 하나로 떨어져야 하기 때문이다. * - *
headlessFirst 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 + *
headlessFirst·headlessAllowed 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 * FAIL_ON_NULL_FOR_PRIMITIVES 가 기본 on 이라, 이 선택 필드를 안 보내는 구버전 호출자의 요청이 400 으로 - * 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 headlessFirst 와 같다. + * 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 같은 이름 파라미터와 같다. + * + *
headlessAllowed 의 누락 정규화가 false 인 것은 fail-safe 다 — 이 필드를 모르는 구버전 호출자의 요청은 + * "허가 없음"으로 떨어져 헤드리스를 타지 않는다. 반대로 두면 허가 계약이 배포 순서에 따라 조용히 뚫린다. * *
model 은 호출자가 지정한 LLM 모델이며 선택 필드다. 안 보내면 null 이 되어 기본 모델을 쓴다(String 이라 * primitive 함정은 없다). 빈 문자열·공백 처리는 GeminiHttpClient 의 후보 계산이 흡수한다. */ -public record LinkExtractionRequest(String url, Boolean headlessFirst, String model) { +public record LinkExtractionRequest(String url, Boolean headlessFirst, Boolean headlessAllowed, String model) { public LinkExtractionRequest { headlessFirst = Boolean.TRUE.equals(headlessFirst); + headlessAllowed = Boolean.TRUE.equals(headlessAllowed); } } 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 ae3b87c..9d47dee 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java @@ -22,6 +22,10 @@ * 최적화로만 남긴다는 설계를 이 승격이 지킨다. * * + *
단 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)는 전부 요청의 headlessAllowed 허가 뒤에 있다 — 허가가 + * 없으면 "비싸고 느려서" 가 아니라 "써서는 안 되므로" 열리지 않는다. 이 서비스는 허가 대상을 알지 못하고 판정도 + * 하지 않는다(무상태): 허가의 단일 진실은 호출자 쪽에 있고 여기서는 요청 단위 플래그로만 받는다. + * *
에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과 * 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서 * 여기서 별도로 판정한다. @@ -54,9 +58,12 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor { private final HeadlessExtractionProperties headlessProperties; @Override - public ProductSnapshot extract(ProductLink link, boolean headlessFirst, String model) { - // 호출자 정책이 이 서비스의 스위치를 앞설 수는 없다 — 스위치가 꺼져 있으면 headlessFirst 힌트도 무시한다. - if (!headlessProperties.enabled()) { + public ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model) { + // 두 조건이 모두 서 있어야 헤드리스가 열린다. enabled 는 이 서비스의 운영 비상 차단(호출자 정책이 앞설 수 + // 없다), headlessAllowed 는 이 대상에 브라우저를 써도 되는지에 대한 호출자의 허가다. 둘 중 하나라도 + // 없으면 plain 만 타고 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)가 한꺼번에 닫힌다 — 아래 분기가 + // 전부 이 가드 뒤에 있는 것이 그 보장이다. headlessFirst 는 허가가 선 뒤에만 의미를 갖는 라우팅 힌트다. + if (!headlessProperties.enabled() || !headlessAllowed) { return plain.extract(link, model); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java index 2a8d3a1..fa78e46 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java @@ -9,12 +9,15 @@ public interface ProductLinkExtractor { /** * @param headlessFirst 호출자(core)의 플랫폼 라우팅 정책 힌트. 정책의 단일 진실은 호출자 쪽 동적 설정(DB, * 백오피스)에 있고, 무상태인 이 서비스는 요청 단위 힌트로만 받는다. true 면 plain(정적 fetch)을 건너뛰고 - * 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다. - */ - /** + * 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다. 힌트일 뿐이라 + * headlessAllowed 가 false 면 함께 무시된다(허가가 라우팅보다 앞선다). + * @param headlessAllowed 이 대상에 헤드리스를 써도 되는지에 대한 호출자의 허가. false 면 어떤 경로로도 + * 헤드리스를 타지 않는다 — 직행(headlessFirst)·차단 승격·불완전 승격 셋 다 닫힌다. 허가 대상의 단일 + * 진실은 호출자(core) 쪽에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는 + * fail-safe 다({@code LinkExtractionRequest}). * @param model 호출자가 지정한 LLM 모델 힌트. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽 * 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며, * 지정 모델이 사라졌으면 기본 모델로 대체된다. */ - ProductSnapshot extract(ProductLink link, boolean headlessFirst, String model); + ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model); } 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 81944af..4efe841 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java @@ -134,6 +134,21 @@ void headlessFirstHintIgnoredWhenHeadlessDisabled() throws Exception { .andExpect(jsonPath("$.name").value("직접파싱 상품")); } + @Test + @DisplayName("headlessAllowed 를 실어 보내도 요청이 깨지지 않고 plain 경로로 정상 추출한다") + void headlessAllowedFieldIsAcceptedOnTheWire() throws Exception { + // 이 필드는 primitive 로 받으면 안 보낸 요청이 400 으로 깨지는 함정이 있어(headlessFirst 와 같은 이유) + // wire 수용 자체가 계약이다. 필드를 안 보내는 구버전 호출자 쪽은 이 클래스의 나머지 케이스가 상시 검증한다. + stubGeminiClient.reset(); + stubPageFetcher.build = link -> PageContent.of(link, STRUCTURED_HTML); + + mockMvc().perform(post("/internal/extractions/link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"url\": \"https://shop.example.com/p/11\", \"headlessAllowed\": true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("직접파싱 상품")); + } + @Test @DisplayName("LLM 이 상품 페이지가 아니라고 판정하면 422 NOT_PRODUCT_PAGE 를 반환한다") void notProductPage() throws Exception { diff --git a/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java b/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java new file mode 100644 index 0000000..0835a4a --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java @@ -0,0 +1,38 @@ +package com.depromeet.piki.extractor.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 요청 계약의 선택 플래그 정규화. 특히 headlessAllowed 의 기본값은 안전 기본값(fail-safe)이라 계약의 일부다 — + * 이 필드를 모르는 구버전 호출자·필드를 빠뜨린 요청이 "허가받은 것"으로 흘러가면 허가 게이트가 배포 순서에 따라 + * 조용히 뚫린다. + */ +class LinkExtractionRequestTest { + + @Test + @DisplayName("headlessAllowed 를 안 보내면 허가 없음(false)으로 정규화된다") + void missingHeadlessAllowedDefaultsToDenied() { + LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null); + + assertEquals(Boolean.FALSE, request.headlessAllowed()); + } + + @Test + @DisplayName("headlessAllowed=true 를 보내면 허가로 그대로 전달된다") + void explicitHeadlessAllowedIsPreserved() { + LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, true, null); + + assertEquals(Boolean.TRUE, request.headlessAllowed()); + } + + @Test + @DisplayName("headlessFirst 를 안 보내면 false 로 정규화된다") + void missingHeadlessFirstDefaultsToFalse() { + LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null); + + assertEquals(Boolean.FALSE, request.headlessFirst()); + } +} diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java index ab80eea..37af825 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java @@ -20,6 +20,9 @@ *
통합 테스트는 외부 경계(PageFetcher·GeminiClient)만 stub 하고 이 라우팅을 실제로 타므로, 분기 망라는 * 여기 단위에서 한다. 두 전략은 실제 빈이 네트워크/브라우저를 요구해 단위로 세울 수 없어, * LinkExtractionStrategy fake 로 "전략의 결과"만 주입한다. + * + *
헤드리스가 도는 케이스는 전부 headlessAllowed=true 를 넘긴다 — 허가가 헤드리스 진입의 전제 조건이기 + * 때문이다. 허가가 없을 때 3경로가 모두 닫히는지는 아래 headlessAllowed* 케이스가 따로 못박는다. */ class FallbackProductLinkExtractorTest { @@ -70,7 +73,7 @@ void headlessOffDelegatesToPlain() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null); + ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null); assertEquals(snapshot, result); assertEquals(1, plain.calls); @@ -85,7 +88,7 @@ void headlessOffPropagatesEscalatableFailure() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, null)); + assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, true, null)); assertEquals(0, headless.calls); } @@ -100,7 +103,7 @@ void escalatesToHeadlessOnEscalatableFailure() { FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); assertEquals(headlessSnapshot, result); assertEquals(1, plain.calls); @@ -120,7 +123,7 @@ void headlessOnButPlainSucceeds() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null); + ProductSnapshot result = fallback(true, plain, headless).extract(link, false, true, null); assertEquals(snapshot, result); assertEquals(1, plain.calls); @@ -140,7 +143,7 @@ void headlessErrorIsCountedAndPropagated() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); + assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); assertEquals( 1.0, registry.counter("product.extract.escalation", "outcome", "failed", "category", "FETCH_CLIENT_ERROR").count() @@ -160,7 +163,7 @@ void headlessFailureIsCountedAndPropagated() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); + assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); assertEquals(1, plain.calls); assertEquals(1, headless.calls); assertEquals( @@ -180,7 +183,7 @@ void headlessFirstSkipsPlain() { FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null); assertEquals(headlessSnapshot, result); assertEquals(0, plain.calls); @@ -199,7 +202,7 @@ void headlessFirstFailurePropagatesWithoutPlainFallback() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, true, null)); + assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, true, true, null)); assertEquals(0, plain.calls); assertEquals(1, headless.calls); assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count()); @@ -214,7 +217,7 @@ void headlessFirstIgnoredWhenDisabled() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, true, null); + ProductSnapshot result = fallback(false, plain, headless).extract(link, true, true, null); assertEquals(snapshot, result); assertEquals(1, plain.calls); @@ -230,7 +233,7 @@ void nonEscalatableFailureIsNeverEscalated() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null)); + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, true, null)); assertEquals(0, headless.calls); } @@ -246,7 +249,7 @@ void escalatesToHeadlessOnIncompleteSnapshot() { FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); assertEquals(headlessSnapshot, result); assertEquals(1, plain.calls); @@ -266,7 +269,7 @@ void incompleteEscalationReturnsHeadlessResultAsIs() { FakeStrategy headless = new FakeStrategy(l -> incomplete); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); assertEquals(incomplete, result); assertEquals(1, headless.calls); @@ -285,7 +288,7 @@ void headlessFirstIncompleteResultIsCountedFailed() { FakeStrategy headless = new FakeStrategy(l -> incomplete); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null); assertEquals(incomplete, result); assertEquals(0, plain.calls); @@ -302,7 +305,7 @@ void incompleteEscalationFailurePropagatesWithoutReentry() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); assertEquals(1, headless.calls); assertEquals( 1.0, @@ -318,7 +321,7 @@ void headlessOffReturnsIncompleteAsIs() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null); + ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null); assertEquals(incomplete, result); assertEquals(0, headless.calls); @@ -335,7 +338,79 @@ void productSnapshotFailureIsNotEscalated() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, null)); + assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, true, null)); + assertEquals(0, headless.calls); + } + + @Test + @DisplayName("허가가 없으면 plain 이 escalatable 차단으로 막혀도 headless 로 승격하지 않고 예외를 전파한다") + void headlessNotAllowedBlocksEscalation() { + // 허가는 "비싸니 아낀다"가 아니라 "써서는 안 된다"는 계약이라, 차단당했다는 사정이 허가를 대신하지 못한다. + FakeStrategy plain = new FakeStrategy(l -> { + throw PageFetchException.clientError(new RuntimeException("403")); + }); + FakeStrategy headless = new FakeStrategy(l -> { + throw new IllegalStateException("headless 는 호출되면 안 됨"); + }); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + assertThrows( + PageFetchException.class, + () -> fallback(true, plain, headless, registry).extract(link, false, false, null)); + assertEquals(1, plain.calls); + assertEquals(0, headless.calls); + assertEquals( + 0.0, + registry.counter("product.extract.escalation", "outcome", "success", "category", "FETCH_CLIENT_ERROR").count() + ); + } + + @Test + @DisplayName("허가가 없으면 plain 결과가 불완전해도 headless 로 승격하지 않고 그대로 반환한다") + void headlessNotAllowedBlocksIncompleteEscalation() { + // 불완전 승격은 세 진입 경로 중 유일하게 plain 이 예외 없이 끝나는 경로다 — 여기로 허가가 새면 + // 게이트가 반쪽이 된다. 반환값은 응답 경계가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는 기존 계약 그대로다. + FakeStrategy plain = new FakeStrategy(l -> incomplete); + FakeStrategy headless = new FakeStrategy(l -> { + throw new IllegalStateException("headless 는 호출되면 안 됨"); + }); + + ProductSnapshot result = fallback(true, plain, headless).extract(link, false, false, null); + + assertEquals(incomplete, result); + assertEquals(1, plain.calls); + assertEquals(0, headless.calls); + } + + @Test + @DisplayName("허가가 없으면 headlessFirst 직행 힌트가 와도 무시하고 plain 만 탄다") + void headlessNotAllowedBeatsHeadlessFirstHint() { + // 라우팅 힌트(어디로 보낼까)와 허가(써도 되나)는 다른 질문이다 — 힌트가 허가를 만들어내지 못한다. + FakeStrategy plain = new FakeStrategy(l -> snapshot); + FakeStrategy headless = new FakeStrategy(l -> { + throw new IllegalStateException("headless 는 호출되면 안 됨"); + }); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, false, null); + + assertEquals(snapshot, result); + assertEquals(1, plain.calls); + assertEquals(0, headless.calls); + assertEquals(0.0, registry.counter("product.extract.headless_first", "outcome", "success").count()); + } + + @Test + @DisplayName("허가가 없으면 escalatable 이 아닌 실패도 그대로 전파한다 (plain 단독 경로의 기존 계약)") + void headlessNotAllowedPropagatesNonEscalatableFailure() { + FakeStrategy plain = new FakeStrategy(l -> { + throw PageFetchException.blockedHost(); + }); + FakeStrategy headless = new FakeStrategy(l -> { + throw new IllegalStateException("headless 는 호출되면 안 됨"); + }); + + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, false, null)); assertEquals(0, headless.calls); } }