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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ jobs:
steps:
- uses: actions/checkout@v4

# 에러 code 카탈로그 정본을 받아 ExtractionErrorCodeCatalogTest 가 enum 과 대조한다.
# 경로 별칭 shared-infra 는 install.sh 가 로컬에 설치하는 경로와 같다 - 테스트가 경로 하나만 알면 된다.
- name: Checkout extraction contract
uses: actions/checkout@v4
with:
repository: TeamPiKi/infra
ref: main
path: shared-infra
sparse-checkout: contracts
persist-credentials: false

- uses: actions/setup-java@v4
with:
java-version: '25'
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
.claude/commands/
.claude/rules/testing-principles.md

# infra(TeamPiKi/infra) 정본 사본 - 로컬은 install.sh 가, CI 는 ci.yml 의 checkout 스텝이 여기에 놓는다.
# 커밋하면 그 사본이 정본과 어긋난 채 굳으므로(메타 테스트가 잡으려는 것 자체) 무시한다.
shared-infra/

# IDE - 개인 설정 제외
.idea/*
!.idea/codeStyles/
Expand Down
6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ dependencies {

tasks.withType<Test> {
useJUnitPlatform()
// 에러 code 카탈로그(infra 정본의 로컬 사본)를 테스트 입력으로 선언한다 - 소스 트리 밖이라 기본 입력이 아니고,
// 선언하지 않으면 카탈로그만 바뀐 실행이 UP-TO-DATE 로 넘어가 어긋남이 다음 clean 까지 안 드러난다.
// fileTree 는 디렉터리가 없어도(설치 전) 빈 컬렉션이라 빌드를 깨지 않는다.
inputs.files(fileTree("shared-infra/contracts"))
.withPropertyName("extractionContractCatalog")
.withPathSensitivity(PathSensitivity.RELATIVE)
// zstd-jni 의 JNI 네이티브 로드 허용 — Java 25 는 경고만 내지만 미래 JDK 는 차단한다(JEP 472).
// 런타임은 Dockerfile ENTRYPOINT 가 같은 플래그를 준다.
jvmArgs("--enable-native-access=ALL-UNNAMED")
Expand Down
180 changes: 7 additions & 173 deletions docs/api-contract.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.depromeet.piki.extractor.common.exception;

/**
* 실패 응답 body 의 code. docs/api-contract.md 의 code 표와 1:1 이어야 한다 — 추가는 자유(additive),
* 제거·의미 변경 금지. 호출자(core)는 이 값을 전이 판정에 쓰지 않고(status 만 본다) 관측·디버깅에만 쓴다.
* 실패 응답 body 의 code. 정본 카탈로그(TeamPiKi/infra 의 contracts/extraction-error-codes.yaml)와 1:1 이어야
* 하고 ExtractionErrorCodeCatalogTest 가 그것을 강제한다 — 여기에 상수를 더하면 카탈로그도 함께 고친다.
* 추가는 자유(additive), 제거·의미 변경 금지. 호출자(core)는 이 값을 전이 판정에 쓰지 않고(status 만 본다)
* 관측·디버깅에만 쓴다.
* <p>일시/확정 분류의 정본은 각 예외 팩토리의 permanent 플래그다 — 여기 복제하지 않는다.
*/
public enum ExtractionErrorCode {
Expand All @@ -17,8 +19,10 @@ public enum ExtractionErrorCode {
EMPTY_SHELL,
/**
* 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정한 것(LlmInputGate) — 빈 입력의 LLM 은
* 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL(일시, 에스컬레이션 대상)과 달리 확정이다 — plain 경로는
* 셸 재분류가 선행하므로, 이 code 는 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다.
* 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL 과 확정/일시 축에서는 같고(둘 다 확정), 갈리는 축은
* 에스컬레이션이다 — EMPTY_SHELL 은 브라우저면 뚫릴 수 있어 승격되지만({@code PageFetchException.emptyShell}
* 의 escalatable 이 정본), 이 code 는 승격 대상이 아니다. plain 경로는 셸 재분류가 선행하므로, 이 code 는
* 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다.
*/
NO_EXTRACTABLE_CONTENT,
LLM_INVALID_RESPONSE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
* @param baseUrl 렌더 서비스 주소(사설망, 예: {@code http://<headless-private-ip>:8000}). 내부 주소라 코드·yml 에
* 박지 않고 env 로만 주입한다.
* @param readTimeout 렌더 서비스 내부 최악(goto + 렌더 정착 대기)을 다 기다리지는 않는다 — 호출자 read 예산 안에
* LLM fallback 몫을 남겨야 하기 때문이다. 층별 예산의 정본은 docs/api-contract.md §3. 상한을 넘긴 렌더는 일시
* 실패로 떨어져 호출자 재시도가 진다.
* LLM fallback 몫을 남겨야 하기 때문이다. 층별 예산의 정본은 TeamPiKi/infra 의 contracts/extraction-api.md
* "타임아웃 예산" 절. 상한을 넘긴 렌더는 일시 실패로 떨어져 호출자 재시도가 진다.
* @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이라
* compress 필드를 모르는 구버전 renderer(무시하고 plain JSON 으로 답한다)와도 호환된다 — 켜 둔 채로 배포
* 순서와 무관하게 안전하고, 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class GeminiHttpClient implements GeminiClient {

/**
* LLM 응답이 길어질 수 있어 넉넉히 두되, 호출자 read 예산 안에 들도록 상한을 둔다
* (층별 예산의 정본은 docs/api-contract.md §3).
* (층별 예산의 정본은 TeamPiKi/infra 의 contracts/extraction-api.md "타임아웃 예산" 절).
*/
private static final int READ_TIMEOUT_MS = 30_000;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package com.depromeet.piki.extractor.common.exception;

import static org.junit.jupiter.api.Assertions.fail;

import com.depromeet.piki.extractor.common.storage.ImageStorageException;
import com.depromeet.piki.extractor.domain.ProductLinkException;
import com.depromeet.piki.extractor.domain.ProductSnapshotException;
import com.depromeet.piki.extractor.extraction.gemini.GeminiApiException;
import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException;
import com.depromeet.piki.extractor.extraction.http.PageFetchException;
import com.depromeet.piki.extractor.image.domain.ProductImageException;
import com.depromeet.piki.extractor.probe.ModelProbeException;
import java.io.IOException;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.yaml.snakeyaml.Yaml;

/**
* 실패 code 계약을 infra 정본 카탈로그에 묶는 메타 테스트.
*
* <p>enum 과 계약 문서를 사람 손으로만 맞추면 한쪽만 고쳐도 아무것도 깨지지 않는다(실제로 core 쪽에서
* 어긋남이 CI 초록불 상태로 발견됐다). 목록도 분류도 사람 판단이 낄 여지가 없어 기계로 가른다.
*
* <p>두 검사는 다른 질문에 답한다 — 하나는 "code 목록이 같은가", 다른 하나는 "그 code 의 분류가 실제
* 동작과 같은가"다. 후자는 카탈로그 값을 예외 팩토리의 실물 플래그와 대조한다. 런타임 정본은 여전히
* 팩토리이며(카탈로그가 판정 입력이 되면 정본이 둘이 된다) 카탈로그는 그 플래그의 계약 표기다.
*
* <p>Spring 컨텍스트를 띄우지 않으므로 단독 실행 가능하다
* ({@code ./gradlew test --tests "com.depromeet.piki.extractor.common.exception.ExtractionErrorCodeCatalogTest"}).
*/
class ExtractionErrorCodeCatalogTest {

/** 로컬은 install.sh 가, CI 는 ci.yml 의 checkout 스텝이 같은 경로에 놓는다 - 그래서 경로가 하나뿐이다. */
private static final Path CATALOG = Path.of("shared-infra/contracts/extraction-error-codes.yaml");

private static final String DISPOSITION = "disposition";
private static final String ESCALATABLE = "escalatable";

@Test
@DisplayName("ExtractionErrorCode 상수 집합은 infra 카탈로그의 code 집합과 정확히 일치한다")
void enumMatchesCatalog() {
Set<String> catalogCodes = catalogEntries().keySet();
Set<String> enumCodes = Arrays.stream(ExtractionErrorCode.values())
.map(Enum::name)
.collect(Collectors.toCollection(LinkedHashSet::new));

// 단언 라이브러리의 집합 비교 대신 양방향 차집합을 직접 만든다 - 어느 쪽에 무엇이 없는지가 실패 메시지의
// 전부인데, 한 번에 양쪽을 다 보여줘야 두 번 돌리지 않고 고칠 수 있다.
Set<String> enumOnly = new TreeSet<>(enumCodes);
enumOnly.removeAll(catalogCodes);
Set<String> catalogOnly = new TreeSet<>(catalogCodes);
catalogOnly.removeAll(enumCodes);

if (enumOnly.isEmpty() && catalogOnly.isEmpty()) {
return;
}
StringBuilder message = new StringBuilder("ExtractionErrorCode 와 infra 카탈로그(" + CATALOG + ")가 어긋난다.\n");
if (!enumOnly.isEmpty()) {
message.append(" enum 에만 있음 (카탈로그에 추가하라): ").append(String.join(", ", enumOnly)).append('\n');
}
if (!catalogOnly.isEmpty()) {
message.append(" 카탈로그에만 있음 (enum 에 추가했거나, 카탈로그에서 지워야 한다): ")
.append(String.join(", ", catalogOnly)).append('\n');
}
fail(message.toString());
}

@Test
@DisplayName("카탈로그의 disposition·escalatable 은 예외 팩토리가 실제로 세우는 플래그와 일치한다")
void catalogFlagsMatchFactories() {
Map<String, Map<String, Object>> catalog = catalogEntries();
List<String> mismatches = new ArrayList<>();
Set<String> covered = new TreeSet<>();

for (FactoryCase factoryCase : factoryCases()) {
ExtractionException exception = factoryCase.exception();
String code = exception.code().name();
covered.add(code);

Map<String, Object> entry = catalog.get(code);
if (entry == null) {
// 목록 어긋남은 위 테스트가 지목한다. 여기서는 NPE 로 죽지 않게만 하고 넘어간다.
continue;
}

String actualDisposition = exception.permanent() ? "permanent" : "transient";
Object declaredDisposition = entry.get(DISPOSITION);
if (!actualDisposition.equals(declaredDisposition)) {
mismatches.add(code + " disposition: 카탈로그=" + declaredDisposition
+ " / " + factoryCase.name() + "=" + actualDisposition);
}

// escalatable 은 fetch 경로에만 있는 축이라 카탈로그가 선언한 code 만 본다. 선언이 없는 code
// (LLM·이미지·헤드리스·probe)에 이 검사를 강요하면 축 밖의 팩토리에 없는 값을 요구하게 된다.
if (!entry.containsKey(ESCALATABLE)) {
continue;
}
if (!(exception instanceof PageFetchException fetchException)) {
mismatches.add(code + " escalatable: 카탈로그가 선언했으나 " + factoryCase.name()
+ " 은 fetch 경로(PageFetchException)가 아니라 이 축을 갖지 않는다");
continue;
}
Object declaredEscalatable = entry.get(ESCALATABLE);
if (!Objects.equals(declaredEscalatable, fetchException.escalatable())) {
mismatches.add(code + " escalatable: 카탈로그=" + declaredEscalatable
+ " / " + factoryCase.name() + "=" + fetchException.escalatable());
}
}

// 표에 없는 code 는 이 테스트가 아무것도 보증하지 않는다. code 만 늘고 대조가 안 늘면 커버리지가
// 조용히 줄어들므로, 그 자체를 실패로 본다.
Set<String> uncovered = new TreeSet<>(catalog.keySet());
uncovered.removeAll(covered);
if (!uncovered.isEmpty()) {
mismatches.add("팩토리 표에 없어 분류가 무보증인 code (factoryCases 에 추가하라): "
+ String.join(", ", uncovered));
}

if (!mismatches.isEmpty()) {
fail("카탈로그(" + CATALOG + ")와 예외 팩토리의 분류가 어긋난다.\n " + String.join("\n ", mismatches));
}
}

private record FactoryCase(String name, ExtractionException exception) {}

/**
* code 를 만드는 팩토리 전수. 리플렉션으로 긁지 않고 명시 호출로 두는 이유는, 팩토리 시그니처가 바뀌면
* 런타임이 아니라 컴파일에서 깨져야 하기 때문이다.
*
* <p>한 code 를 여러 팩토리가 만들면 전부 넣는다(UPSTREAM_ERROR ← connect 실패·빈 body,
* LLM_UPSTREAM ← 5xx·429·408·빈 응답 등). 같은 code 를 만드는 팩토리끼리 플래그가 갈리면 그 자체가
* 문제 신호라, 표에 다 있어야 드러난다.
*/
private static List<FactoryCase> factoryCases() {
Throwable cause = new IllegalStateException("catalog contract test");
return List.of(
new FactoryCase("PageFetchException.upstreamError", PageFetchException.upstreamError(cause)),
new FactoryCase("PageFetchException.emptyBody", PageFetchException.emptyBody()),
new FactoryCase("PageFetchException.permanentUpstreamError", PageFetchException.permanentUpstreamError(cause)),
new FactoryCase("PageFetchException.clientError", PageFetchException.clientError(cause)),
new FactoryCase("PageFetchException.emptyShell", PageFetchException.emptyShell(cause)),
new FactoryCase("PageFetchException.tooManyRedirects", PageFetchException.tooManyRedirects()),
new FactoryCase("PageFetchException.malformedRedirect", PageFetchException.malformedRedirect(cause)),
new FactoryCase("PageFetchException.blockedHost", PageFetchException.blockedHost()),

new FactoryCase("ProductSnapshotException.notProductPage", ProductSnapshotException.notProductPage()),
new FactoryCase("ProductSnapshotException.untrustworthyValue", ProductSnapshotException.untrustworthyValue()),
new FactoryCase("ProductSnapshotException.noExtractableContent", ProductSnapshotException.noExtractableContent()),

new FactoryCase("ProductLinkException.blank", ProductLinkException.blank()),
new FactoryCase("ProductLinkException.invalidFormat", ProductLinkException.invalidFormat(cause)),
new FactoryCase("ProductLinkException.unsupportedScheme", ProductLinkException.unsupportedScheme()),

new FactoryCase("GeminiApiException.upstreamError", GeminiApiException.upstreamError(cause)),
new FactoryCase("GeminiApiException.emptyResponse", GeminiApiException.emptyResponse()),
new FactoryCase("GeminiApiException.clientError", GeminiApiException.clientError(cause)),
new FactoryCase("GeminiApiException.parseError", GeminiApiException.parseError(cause)),
new FactoryCase("GeminiApiException.noTextPart", GeminiApiException.noTextPart()),
// status 로 code 가 갈리는 유일한 팩토리라 양쪽 갈래를 다 넣는다 - 429·408 은 4xx 지만 일시다.
new FactoryCase(
"GeminiApiException.fromResponseError(500)",
GeminiApiException.fromResponseError(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR))),
new FactoryCase(
"GeminiApiException.fromResponseError(429)",
GeminiApiException.fromResponseError(new HttpClientErrorException(HttpStatus.TOO_MANY_REQUESTS))),
new FactoryCase(
"GeminiApiException.fromResponseError(408)",
GeminiApiException.fromResponseError(new HttpClientErrorException(HttpStatus.REQUEST_TIMEOUT))),
new FactoryCase(
"GeminiApiException.fromResponseError(400)",
GeminiApiException.fromResponseError(new HttpClientErrorException(HttpStatus.BAD_REQUEST))),

new FactoryCase("HeadlessRenderException.blocked", HeadlessRenderException.blocked()),
new FactoryCase("HeadlessRenderException.upstream", HeadlessRenderException.upstream("test", cause)),

new FactoryCase("ProductImageException.emptyImage", ProductImageException.emptyImage()),
new FactoryCase("ProductImageException.unknownType", ProductImageException.unknownType()),
new FactoryCase("ProductImageException.unsupportedType", ProductImageException.unsupportedType()),

new FactoryCase("ImageStorageException.uploadFailed", ImageStorageException.uploadFailed(cause)),
new FactoryCase("ImageStorageException.downloadFailed", ImageStorageException.downloadFailed(cause)),

// probe 전용 code. 추출 경로가 아니라 escalatable 축 밖이고, 카탈로그도 scope: probe 로만 표시한다.
new FactoryCase("ModelProbeException.notFound", ModelProbeException.notFound()),
new FactoryCase("ModelProbeException.incompatible", ModelProbeException.incompatible())
);
}

/**
* 카탈로그의 code 별 속성 맵. 파일이 없어도 skip 하지 않고 실패시킨다 - skip 은 강제가 조용히 무너지는
* 길이라, "정본을 못 읽었다" 는 어긋남과 똑같이 취급해야 한다.
*/
private static Map<String, Map<String, Object>> catalogEntries() {
if (!Files.isRegularFile(CATALOG)) {
fail("infra 카탈로그를 찾지 못했다: " + CATALOG.toAbsolutePath()
+ "\n 로컬이면 infra 의 install.sh 를 실행하고, CI 면 ci.yml 의 'Checkout extraction contract' 스텝을 확인하라.");
}
Object root;
try (Reader reader = Files.newBufferedReader(CATALOG)) {
root = new Yaml().load(reader);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
if (!(root instanceof Map<?, ?> document) || !(document.get("codes") instanceof Map<?, ?> codes)) {
return fail("카탈로그 형식이 어긋난다 - 최상위에 code 이름을 키로 갖는 codes 맵이 있어야 한다: " + CATALOG);
}
Map<String, Map<String, Object>> entries = new LinkedHashMap<>();
codes.forEach((code, attributes) -> entries.put(String.valueOf(code), attributesOf(attributes)));
return entries;
}

/** 속성 없는 code(값이 비어 있는 항목)도 목록 대조 대상이므로 빈 맵으로 받아 넘긴다. */
private static Map<String, Object> attributesOf(Object attributes) {
if (!(attributes instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, Object> normalized = new LinkedHashMap<>();
map.forEach((key, value) -> normalized.put(String.valueOf(key), value));
return normalized;
}
}
Loading