diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45a943d..1f28f9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.gitignore b/.gitignore index a963bb1..1758dae 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/build.gradle.kts b/build.gradle.kts index 2a159b4..f5e68fe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,12 @@ dependencies { tasks.withType { 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") diff --git a/docs/api-contract.md b/docs/api-contract.md index ece4c45..2837059 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1,178 +1,12 @@ -# extractor API 계약 +# extractor API 계약 (이전됨) -이 문서가 계약의 single source 다. 구현(springdoc `/v3/api-docs`)과 어긋나면 이 문서를 기준으로 구현을 고친다. +계약 정본은 이 repo 를 떠나 **[TeamPiKi/infra](https://github.com/TeamPiKi/infra)** 로 옮겼다. -소비자는 core 의 outbox 워커 하나뿐이다. 공개 API 가 아니며, 보안그룹으로 내부망에서만 접근한다(별도 인증 없음). - -## 0. 설계 불변식 - -- 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갈래 (전이 규약) - -| Extractor 응답 | 의미 | core 전이 | -|---|---|---| -| 2xx + 추출 결과 | 성공 | `markReady` | -| 422 + `{code}` | 확정 실패 (재시도 무의미) | 즉시 `markFailed` | -| 그 외 전부 (5xx·타임아웃·연결 실패·미지의 상태) | 일시 실패 | PROCESSING 유지 → recover 재시도 (attempt 상한 2) | - -- **전이 판정은 HTTP status 만 사용한다.** 422 body 의 `code` 는 관측·디버깅용이며, 호출자가 모르는 code 여도 422 면 확정 실패로 처리한다(tolerant reader). -- **fail-safe 원칙**: 분류할 수 없는 실패는 전부 "일시"로 떨어진다. 확정 실패 신호는 422 하나뿐이다. 호출자의 attempt 상한이 재시도 비용을 바운드한다. - -## 2. 엔드포인트 - -### POST `/internal/extractions/link` — URL 상품 추출 (이관 1차) - -요청: - -```json -{ "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` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선). `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 상관용이며 동작에 영향 없다. - -성공 200: - -```json -{ - "name": "나이키 에어포스", - "imageUrl": "https://...", - "currentPrice": 99000, - "currency": "KRW", - "finalUrl": "https://www.musinsa.com/products/6760200", - "method": "STRUCTURED" -} -``` - -- `finalUrl` (additive, core#825): 리다이렉트를 따라간 최종 페이지 URL. 호출자가 상품 정체성(canonical) 정규화의 입력으로 쓴다 — 단축링크(onelink 등)는 경로가 불투명 코드라 이 값 없이 같은 상품을 알아볼 수 없다. link 경로는 항상 채워지고 image 경로는 null. 호출자는 이 값이 없으면(구버전 Extractor) canonical 확정을 건너뛴다 — 배포 순서 무관. -- `method` (additive, core#825): 값을 만든 추출 경로. `STRUCTURED`(구조화 파싱, 결정론적) | `LLM`(Gemini — URL fallback·image 경로). 호출자가 snapshot 출처(SERVER/SERVER_LLM)를 구분 저장하는 근거다. tolerant reader 라 모르는 값이 와도 무시하고 출처 미기록으로 둔다. -- **`name`(non-blank)·`imageUrl`·`currentPrice` 의 non-null 을 Extractor 가 보장한다** — core 의 READY 불변식(`requireReadyInvariant`: name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과 동일 조건이다. 보장할 수 없으면 성공이 아니라 422(`UNTRUSTWORTHY_VALUE`)다. `currency` 는 READY 필수가 아니라 **nullable** 이다. 호출자의 엔티티 불변식은 최후 보루로 유지된다. - -확정 실패 422: - -```json -{ "code": "NOT_PRODUCT_PAGE" } -``` - -| code | core 기준 동등물 | +| 무엇 | 어디 | |---|---| -| `NOT_PRODUCT_PAGE` | `ProductSnapshotException.notProductPage` — 상품 페이지가 아님 | -| `UNTRUSTWORTHY_VALUE` | `ProductSnapshotException.untrustworthyValue` — 추출값이 범위·상식 위반 | -| `FETCH_CLIENT_ERROR` | `PageFetchException.clientError` — 대상 4xx (403 차단·404·429 등) | -| `BLOCKED_HOST` | `PageFetchException.blockedHost` — SSRF 차단 (headless 에스컬레이션 절대 금지 대상) | -| `TOO_MANY_REDIRECTS` | `PageFetchException.tooManyRedirects` | -| `MALFORMED_REDIRECT` | `PageFetchException.malformedRedirect` | -| `PERMANENT_UPSTREAM` | `PageFetchException.permanentUpstreamError` — 대상 500/501 (봇 차단 추정) | -| `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 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) | - -일시 실패 (Extractor 는 502 를 쓴다, 호출자는 status 구분 없이 "2xx/422 외 전부"로 처리): - -- 대상 몰 502/503/504·연결 실패·빈 body (`UPSTREAM_ERROR`) -- Gemini 5xx/429/408/transport 오류 (`LLM_UPSTREAM`) -- 헤드리스 렌더 차단 (`HEADLESS_BLOCKED`, 이관 7단계 추가) — 렌더 서비스의 BLOCK 판정(HTTP 401/403/405/429/490 + 챌린지 title)은 429·일시 챌린지가 섞여 영구/일시를 못 가르므로 fail-safe 로 일시. 결정론적 차단의 재시도 낭비는 attempt 상한이 바운드 -- 헤드리스 렌더 서비스 연결 실패·타임아웃·빈 렌더(verdict=EMPTY)·브라우저 오류(verdict=ERROR)·압축(zstd) 응답 해제 실패 (`HEADLESS_UPSTREAM`, 이관 7단계 추가). 렌더 서비스(renderer)는 파싱하지 않으므로(verdict=OK|BLOCK|EMPTY|ERROR) HTML 이 있으면 verdict 와 무관하게 Extractor 파이프라인(구조화·LLM)이 추출을 이어간다 -- body 에 code 를 실을 수 있으나 호출자는 읽지 않는다(관측용). - -### POST `/internal/extractions/image` — S3 이미지 OCR 추출 + 크롭 (이관 6단계, 계약만 선확정) - -요청: - -```json -{ "bucket": "dev-piki-images-996918499382", "key": "items/raw/0f3a....png", "model": "gemini-3.1-flash-lite" } -``` - -- **`bucket` 을 요청이 준다** — Extractor 는 dev/staging/prod 세 환경 트래픽을 받고 각 환경의 이미지 버킷이 다르다(dev-piki-images-* · staging-piki-images-* · piki-images-*). 버킷을 고정 config 로 두지 않고 요청별로 받아 버킷 무관하게 동작한다. IAM 은 `*piki-images-996918499382/items/*` 와일드카드로 세 버킷을 덮는다. -- `key`: raw 원본 object key(등록 시 본 서버가 `items/raw/{uuid}.{ext}` 로 durable 적재한 것). -- `model` (선택, additive — core#875): link 와 같은 규약이되 **축이 갈린다** — 이미지 경로에는 이미지용 지정만 온다. 링크는 텍스트와 JSON 스키마를 다루고 이미지는 보는 능력이 필요해, 한쪽에 맞는 모델이 다른 쪽에 맞지 않을 수 있기 때문이다. 대체 규칙(404 만 기본 모델로)도 link 와 같다. - -성공 200 (link 경로와 **동일한 필드 모양**): - -```json -{ - "name": "...", - "imageUrl": "https://dev-piki-images-996918499382.s3.ap-northeast-2.amazonaws.com/items/0f3a....png", - "currentPrice": 12000, - "currency": "KRW", - "finalUrl": null, - "method": "LLM" -} -``` - -- Extractor 가 `download(bucket,key) → OCR 추출 → bbox 크롭(불가 시 원본) → upload(bucket, items/{uuid}.png)` 를 다 하고, 업로드한 결과 이미지의 public URL 을 `imageUrl` 로 돌려준다. imageUrl 은 항상 non-null(크롭 실패해도 원본을 올린다 — 본 서버 워커의 `bbox?.crop ?: 원본` 동작과 동일). -- non-null 규약은 link 와 같다: name(non-blank)·imageUrl·currentPrice 를 Extractor 가 보장, 못 채우면 422(`UNTRUSTWORTHY_VALUE`). - -422 code 추가분: `IMAGE_UNSUPPORTED`(빈 이미지·미지원 MIME). 이미지에서 상품 식별 실패는 link 와 같은 `UNTRUSTWORTHY_VALUE` 를 재사용한다. -일시 실패 추가분: `STORAGE_ERROR` (S3 read/write 실패). - -### POST `/internal/models/probe` — 모델 유효성 프로브 (core#875) - -호출자가 백오피스에서 모델을 저장하기 전에 "이 모델이 이 경로에서 실제로 동작하는가"를 묻는다. 저장 게이트가 이 응답으로 갈린다. - -요청: - -```json -{ "model": "gemini-3.1-flash-lite", "target": "LINK" } -``` - -- `model` (필수): 확인할 모델 이름. 아는 모델 목록을 Extractor 코드에 박지 않는 것이 이 엔드포인트의 존재 이유다 — allowlist 를 박으면 새 모델이 나올 때마다 Extractor 배포가 필요해져 "배포 없이 바꾼다"는 목적이 무너진다. 유효성은 런타임 실측이 판정한다. -- `target` (필수): `LINK` 또는 `IMAGE`. 두 경로는 요청 wire 가 달라(link 는 `responseJsonSchema` 에 소문자 type, image 는 `responseSchema` 에 대문자 enum type 과 thinkingConfig) 한쪽에서 통과한 모델이 다른 쪽에서 400 일 수 있다. - -**판정은 메타 조회가 아니라 그 경로의 실제 generateContent 호출이다.** 모델 존재만 확인하면 요청 스키마 비호환(400)을 못 거르는데, 400 은 추출 경로에서 대체 대상이 아니라 곧 파싱 전건 실패다. 게이트가 정작 막아야 할 실패를 놓치게 된다. 최소 입력을 쓰되 wire 모양은 운영과 같다. - -**대체 없이 지정 모델만 친다.** 추출 경로처럼 대체하면 없는 모델을 넣어도 기본 모델이 대신 성공해 프로브가 통과하고, 저장 게이트가 무력화된다. - -응답: - -| status | 의미 | 호출자의 처리 | -|---|---|---| -| `200` (body 없음) | 이 경로에서 동작하는 모델 | 저장 허용 | -| `422` + code | 확정 거절 | 저장 거부 + 사유 표시 | -| `400` | 필수 필드 누락·모르는 target | 호출자 구현 버그. 재시도해도 같다 | -| 그 외(502) | 외부 사정으로 확인 불가 | 저장 거부 + 재시도 안내 | - -422 code: - -| code | 의미 | -|---|---| -| `MODEL_NOT_FOUND` | 그런 모델이 없다(Gemini 404). 오타이거나 폐기돼 사라진 모델 | -| `MODEL_INCOMPATIBLE` | 모델은 있으나 그 경로의 요청을 처리하지 못한다. 요청 스키마 비호환(400)·결제 티어 제한, 그리고 200 을 주면서 응답 스키마를 못 맞춘 경우까지 포함 | - -**일시 실패를 거절로 바꾸지 않는다.** 5xx·429·타임아웃을 422 로 내보내면 외부가 잠깐 흔들린 사이에 멀쩡한 모델이 "쓸 수 없는 모델"로 판정돼 저장이 막힌다. - -## 3. 타임아웃 예산 - -| 층 | 값 | 근거 | -|---|---|---| -| core stale 판정 | 60s | `ItemParsingScheduler.STALE_TIMEOUT` (기존 값) | -| core → Extractor HTTP read | 55s (connect 2s) | stale 미만 — recover 의 유령 중복 발주 방지. link·image 공용 | -| Extractor 내부 합계 (link) | 약 50s | 아래 합 + 여유 | -| 대상 몰 fetch (link) | connect 5s / read 15s | 현행 유지 | -| 헤드리스 render (link, 7단계) | connect 2s / read 20s | 실측 전형 1.6~5.5s(kream 프록시 포함) 대비 약 4배 여유. headless-first 최악(connect 2 + render 20 + LLM 30 = 약 52s)이 호출자 read 55s 안에 들도록 상한 | -| Gemini | read 30s | 현행 유지 (link LLM fallback·image OCR 동일) | -| Extractor 내부 합계 (image) | 약 40s | S3 download + Gemini OCR 30s + crop + 결과 upload. S3 는 동일 리전이라 수 초 | - -**안쪽 예산은 항상 바깥보다 작아야 한다.** link 와 image 가 호출자 read 55s 를 공유하므로, 어느 경로든 Extractor 내부 값을 늘릴 땐 이 표를 갱신하고 core 쪽 read 타임아웃과 함께 재검증한다. - -예외적으로 **에스컬레이션 경로(plain 실패 → headless)의 최악 스택**은 호출자 read 55s 를 넘을 수 있다. plain fetch 는 수동 redirect 추적(hop 상한 3 = 요청 최대 4회)마다 connect/read 타임아웃이 **새로 적용**되므로 fetch 단독의 이론 최악이 이미 약 88s 다(이 특성은 headless 이전부터 존재). 여기에 render 22s + LLM 30s 가 얹히면 이론 최악 약 140s — 단, 각 단이 전부 타임아웃까지 끄는 경우는 실측상 없다시피 하고(차단은 대개 즉시 4xx/5xx 로 떨어져 fetch 가 빨리 실패한다), 넘치면 호출자는 read 타임아웃 → 일시 실패로 처리해 recover 가 재시도한다. 그 사이 Extractor 가 계속 돌아 중복 발주가 겹쳐도 Extractor 는 무상태라 안전하고(§0 — 중복의 대가는 LLM 비용 한 번), attempt 상한 2 가 총비용을 바운드한다. 이 스택을 55s 안에 구겨 넣으려면 render 예산이 실측 대비 무의미하게 얇아져(5s 이하) recall 을 잃는다 — 의도된 트레이드오프다. - -## 4. 진화 규칙 - -- **additive-only**: 응답 필드 추가·422 code 추가는 자유. 필드 제거·의미 변경·타입 변경은 금지 — 필요하면 새 경로로 분리한다. -- **배포 순서: Extractor 먼저, 소비자(core) 나중.** 본 서버 staging 이 extractor-prod 를 기본으로 바라보는 구성이 이 순서 위반을 릴리스 전에 잡는다. -- 호출자는 tolerant reader — 모르는 응답 필드·code 를 무시한다. +| 계약 본문 (엔드포인트·응답 3갈래·타임아웃 예산·진화 규칙) | `contracts/extraction-api.md` | +| 실패 code 카탈로그 (기계 판정용) | `contracts/extraction-error-codes.yaml` | -## 5. 관측 +**왜 옮겼나**: 계약은 extractor 와 소비자(core) 양쪽이 지키는 약속인데 정본이 한쪽 repo 안에 있으면 다른 쪽은 사본을 들게 되고, 사본은 조용히 어긋난다(계약 문서가 명시한 code 의 core 동등물이 실제로는 구현되지 않은 채 양쪽 CI 가 초록불이던 사례). 정본을 공용 repo 로 올리고, code 목록처럼 기계로 가를 수 있는 부분은 카탈로그로 떼어 각 repo 의 CI 가 대조한다 — 이 repo 에서는 `ExtractionErrorCodeCatalogTest` 가 `ExtractionErrorCode` 와 카탈로그의 일치를 강제한다. -- W3C `traceparent` 헤더를 수용해 core 의 `item.parse` span 아래로 연결된다(micrometer tracing 기본 동작). -- 메트릭 `product.extract{via,reason}`·`product.extract.escalation{outcome,category}` 은 core 에서 이 서비스로 이동한다. `application=piki-extractor` 라벨로 본 서버 시계열과 구분된다. +카탈로그는 로컬에서 infra 의 `install.sh` 가, CI 에서는 `ci.yml` 의 checkout 스텝이 `shared-infra/contracts/` 에 놓는다. 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 2cd6ec7..f9fd47f 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 @@ -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 만 본다) + * 관측·디버깅에만 쓴다. *

일시/확정 분류의 정본은 각 예외 팩토리의 permanent 플래그다 — 여기 복제하지 않는다. */ public enum ExtractionErrorCode { @@ -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, diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java index 03985a8..c6524ff 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java @@ -13,8 +13,8 @@ * @param baseUrl 렌더 서비스 주소(사설망, 예: {@code http://: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 다. 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 c965f17..fcc803f 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 @@ -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; 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 new file mode 100644 index 0000000..55e9ff7 --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java @@ -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 정본 카탈로그에 묶는 메타 테스트. + * + *

enum 과 계약 문서를 사람 손으로만 맞추면 한쪽만 고쳐도 아무것도 깨지지 않는다(실제로 core 쪽에서 + * 어긋남이 CI 초록불 상태로 발견됐다). 목록도 분류도 사람 판단이 낄 여지가 없어 기계로 가른다. + * + *

두 검사는 다른 질문에 답한다 — 하나는 "code 목록이 같은가", 다른 하나는 "그 code 의 분류가 실제 + * 동작과 같은가"다. 후자는 카탈로그 값을 예외 팩토리의 실물 플래그와 대조한다. 런타임 정본은 여전히 + * 팩토리이며(카탈로그가 판정 입력이 되면 정본이 둘이 된다) 카탈로그는 그 플래그의 계약 표기다. + * + *

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 catalogCodes = catalogEntries().keySet(); + Set enumCodes = Arrays.stream(ExtractionErrorCode.values()) + .map(Enum::name) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + // 단언 라이브러리의 집합 비교 대신 양방향 차집합을 직접 만든다 - 어느 쪽에 무엇이 없는지가 실패 메시지의 + // 전부인데, 한 번에 양쪽을 다 보여줘야 두 번 돌리지 않고 고칠 수 있다. + Set enumOnly = new TreeSet<>(enumCodes); + enumOnly.removeAll(catalogCodes); + Set 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> catalog = catalogEntries(); + List mismatches = new ArrayList<>(); + Set covered = new TreeSet<>(); + + for (FactoryCase factoryCase : factoryCases()) { + ExtractionException exception = factoryCase.exception(); + String code = exception.code().name(); + covered.add(code); + + Map 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 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 를 만드는 팩토리 전수. 리플렉션으로 긁지 않고 명시 호출로 두는 이유는, 팩토리 시그니처가 바뀌면 + * 런타임이 아니라 컴파일에서 깨져야 하기 때문이다. + * + *

한 code 를 여러 팩토리가 만들면 전부 넣는다(UPSTREAM_ERROR ← connect 실패·빈 body, + * LLM_UPSTREAM ← 5xx·429·408·빈 응답 등). 같은 code 를 만드는 팩토리끼리 플래그가 갈리면 그 자체가 + * 문제 신호라, 표에 다 있어야 드러난다. + */ + private static List 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> 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> entries = new LinkedHashMap<>(); + codes.forEach((code, attributes) -> entries.put(String.valueOf(code), attributesOf(attributes))); + return entries; + } + + /** 속성 없는 code(값이 비어 있는 항목)도 목록 대조 대상이므로 빈 맵으로 받아 넘긴다. */ + private static Map attributesOf(Object attributes) { + if (!(attributes instanceof Map map)) { + return Map.of(); + } + Map normalized = new LinkedHashMap<>(); + map.forEach((key, value) -> normalized.put(String.valueOf(key), value)); + return normalized; + } +}