feat(F-EXT-001): 문서에 닿지 못한 것을 「AI 장애」와 가른다 — 운영자가 ai-service 를 재시작했다 (#556) - #591
Conversation
…556) ai-service 는 거부를 넷으로 갈라 주는데 `raise()` 가 셋을 하나로 뭉쳤다. 상태와 본문 코드 둘 다 손에 있는데 예외 타입이 그것으로 갈리지 않았다. 400 DocumentPathRejected → 502 AI_SERVICE_UNAVAILABLE 404 DocumentNotFound → 502 AI_SERVICE_UNAVAILABLE 502 code=DOCUMENT_ACCESS_DENIED → 502 AI_SERVICE_UNAVAILABLE 422 DocumentUnreadable → 400 DOCUMENT_UNPROCESSABLE ← 하나만 갈렸다 증상이 전부 「채점 서비스에 연결할 수 없습니다」였다. 볼륨이 root:root 이고 server 가 uid 10001 로 도는 상태(#532 · 결정 7.54)에서 운영자는 그 문면을 보고 ai-service 를 재시작한다 — 아무것도 안 고치고 같은 502 가 다시 온다. ## 새 코드 하나 (DOCUMENT_UNREACHABLE · 502) 셋 중 둘(404 · DOCUMENT_ACCESS_DENIED)은 **다음 행동이 같다** — 배포에서 업로드 볼륨을 본다. 그래서 한 타입·한 코드로 낸다. 기존 코드로는 안 되는 이유가 각각 있다. AI_SERVICE_UNAVAILABLE ai-service 를 재시작하게 만든다. 그게 이 이슈다 DOCUMENT_UNPROCESSABLE 「문서를 고쳐 다시 올려라」다. 문서는 멀쩡하고 올린 사람이 할 수 있는 것이 없다 — 시도만 는다 4xx 요청도 문서도 정상이다. 못 읽는 것이 우리 배포다 400 DocumentPathRejected 는 그대로 둔다 — 그건 우리 배선이 잘못된 경로를 보낸 것이고, 같은 자리에 이미 IllegalStateException 갈래가 있다(스키마 거부). ## 본문을 한 번만 읽는다 `raise()` 가 본문을 인자로 받게 바꿨다. `resp.getBody()` 는 스트림이라 두 번째 읽기가 빈 값을 주고, 갈래마다 따로 읽으면 앞 갈래가 뒤를 먹는다 — `parseFailure` 가 그 함정을 이미 한 번 밟았다(스키마 거부가 DocumentUnreadable 로 샜다). 읽는 자리를 하나로 올려 그 실수가 구조적으로 안 나게 했다. 쓰이지 않게 된 `errorCode()` 는 지웠다. ## 404 를 공용 갈래에 두지 않는다 `/internal/parse` 의 404 만 「문서에 닿지 못했다」다. 다른 내부 경로의 404 는 「그 라우트가 없다」이고, 그것을 볼륨 탓으로 읽으면 오진의 방향만 바뀐다. ## 역검증 DOCUMENT_ACCESS_DENIED 갈래를 죽인다 해당 테스트 빨강 404 갈래를 죽인다 해당 테스트 빨강 404 를 모든 엔드포인트로 넓힌다 과잉 확대 단정 빨강 ← 처음엔 초록이라 그물을 더했다 세 번째가 처음에 통과했다. 「다른 경로의 404 는 문서 문제가 아니다」를 재는 것이 없어서, javadoc 이 근거로 든 구분을 코드가 안 지키고 있었다. ## 여섯 벌 핸들러 · openapi · CLAUDE.md · types.ts · errorText.ts · 명세서 §9(14종 → 15종). 문면 표에 「다시 올려라」를 안 적었다 — 올린 사람이 할 수 있는 것이 없다. 응답 본문에 저장 경로를 안 싣는다(#582 와 같은 규약. 그 단정도 넣었다). `./gradlew test` 821건 통과 · `npm run build` 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gitIt-sehyeon
left a comment
There was a problem hiding this comment.
변이 아홉 개를 다 재현했고 갈래가 정확합니다. 특히 «본문을 한 번만 읽는다» 를 구조로 옮긴 것이 실제로 재집니다 — 되돌려 보면 그물이 뭅니다. 막지 않습니다. 다만 셋 중 하나가 아직 안 갈렸고, 그 하나가 배선 버그가 아니라 운영 상황입니다. 그리고 스택 머지 순서에 함정이 하나 있습니다.
재현한 것
./gradlew test 821 passed (+5) npm run build 통과
변이 아홉입니다.
1. DOCUMENT_ACCESS_DENIED 갈래를 죽인다 빨강
2. parse 404 갈래를 죽인다 빨강
3. 404 를 모든 엔드포인트로 넓힌다 빨강 ◀ 본문에서 「처음엔 초록」이던 그 자리
4. 상태를 502 대신 400 으로 빨강
5. 응답 본문에 e.getMessage() 를 싣는다 빨강 ◀ 경로 비노출이 그물에 걸려 있다
6. 두 갈래를 AiServiceException 으로 되돌린다 빨강
7. raise() 의 path() 를 get() 으로 빨강 ◀ #293 의 방어가 옮긴 뒤에도 재진다
8. errorBody() 의 IOException 폴백을 없앤다 빨강
9. parseFailure 가 본문을 두 번 읽는다 빨강 ◀ 구조로 막았다는 주장이 실제로 재진다
7·8·9 를 따로 잰 이유는 리팩터링이 기존 방어를 옮겼기 때문입니다. errorCode() 가 사라지면서 #293 리뷰가 세운 «path() 를 get() 으로 바꾸면 문자열 detail 에서 NPE» 가 raise() 안으로 이동했는데, 옮긴 뒤에도 그물이 그대로 뭅니다. 9번이 특히 좋습니다 — "읽는 자리를 failure() 하나로 올려 그 실수가 구조적으로 안 나게 했다" 가 문면만이 아니라 재지는 주장입니다.
여섯 벌도 다 맞았습니다(ErrorCodeContractTest 초록 · 14종 → 15종 · 요약표까지).
❗1. 셋 중 하나가 아직 안 갈렸고, 그게 배선 버그가 아닙니다
본문의 표가 400 을 이렇게 둡니다.
400 DocumentPathRejected → 우리 배선 버그. 그대로
네 자리 중 셋은 맞는데 넷째가 다릅니다.
parsing.py:682 document_path 가 비었다 배선 버그
parsing.py:684 NUL 문자 배선 버그
parsing.py:693 경로 해소 실패(순환 심볼릭 링크) 배선/환경
parsing.py:696 허용된 뿌리 밖 배선 버그
parsing.py:749 ❗수동 파스 출력의 product_type 이 다르다 ← 데이터·운영 상황이다
마지막은 #441 의 수동 파스 출력(data/documents/x.json)을 놓은 사람이 상품유형을 틀리게 적은 상태입니다. 운영자가 실제로 만들 수 있는 상태이고, 지금 이렇게 흐릅니다.
400 DocumentPathRejected
→ routes.py 표가 body code 를 안 싣는다 (400, None)
→ raise() 에서 code == null
→ AiServiceException → 502 AI_SERVICE_UNAVAILABLE
→ "채점 서비스에 연결할 수 없습니다"
→ 운영자가 ai-service 를 재시작한다 → 아무것도 안 고쳐진다
이 PR 이 없애려는 그 고리 그대로입니다. 고칠 자리는 그 JSON 파일인데 문면은 ai-service 를 가리킵니다.
다만 여기서 고치시라는 게 아닙니다
DOCUMENT_UNREACHABLE 로 보내면 안 됩니다 — 볼륨을 보라고 하는데 볼륨은 멀쩡합니다. 맞는 자리는 DOCUMENT_UNPROCESSABLE("이 문서를 고쳐 다시 올려라")에 가까운데, 수동 파스 출력은 「올린 문서」가 아니라 옆에 놓은 파일이라 그 문면도 정확히는 아닙니다. 그리고 그러려면 ai-service 가 이 갈래에 body code 를 실어 줘야 합니다(지금 (400, None) 이라 서버가 넷을 구별할 방법이 없습니다) — routes.py 표를 건드리는 일이라 @yoonjiseok 님 파일이고 이 PR 범위 밖입니다.
본문 표의 400 줄만 「배선 버그」에서 「배선 버그 셋 + 수동 파스 출력 불일치 하나 — 뒤쪽은 남는다」로 갈라 적어 주시면 충분합니다. #556 이 «넷 중 셋이 뭉친다» 로 열렸는데 이 PR 이 둘을 갈랐으므로, 하나가 남는다는 사실이 어디엔가 있어야 다음 사람이 그것을 찾습니다. 이슈로 뗄지는 그쪽 판단에 맡기겠습니다 — 필요하면 제가 떼겠습니다.
❗2. 삭제된 메서드를 가리키는 {@link} 가 남았습니다
// AiServiceClient.java:785 (piiKinds 의 javadoc)
* <p>{@link #errorCode} 와 같은 이유로 {@code path()} 만 쓴다 — …errorCode() 는 이 PR 이 지웠습니다. 파일에 남은 errorCode 는 이 한 줄뿐입니다.
그리고 아무것도 이걸 안 잡습니다. 재 봤습니다 — piiKinds 가 private 이라 javadoc 기본 범위 밖이고(그래서 ./gradlew javadoc 이 이 자리를 아예 안 봅니다), javadoc 태스크 자체가 CI 에 없습니다(ci.yml:236 은 ./gradlew test). 게다가 그 태스크는 이 브랜치 이전부터 다른 파일에서 이미 빨갛습니다(AccessGuard·AccessPolicy 등 23건).
raise() 안에 같은 이유가 그대로 적혀 있으니 {@link #raise} 로 돌리거나 링크를 풀면 됩니다. 사소한데 짚는 이유는, 이 레포가 이번 주에 낡은 주석만으로 PR 셋(#573·#583·#585)을 냈고 읽는 사람이 그 이름으로 근거를 찾으러 갈 자리이기 때문입니다.
❗3. 스택 머지 순서 — #586 을 --delete-branch 로 넣으면 이 PR 이 닫힙니다
base 가 test/error-code-sixth-copy-spec 입니다. 본문에 "#586 이 머지되면 여기 base 를 main 으로 돌립니다" 라고 적으셨는데, 순서가 반대면 늦습니다.
#586 을 --delete-branch 로 머지 → 이 PR 이 자동으로 닫힌다 (이 레포에서 실제로 그랬다)
이 PR 을 main 으로 먼저 재타깃 → 그 뒤엔 아무 순서로 넣어도 안전하다
#586 은 제가 방금 승인했으니 곧 들어갑니다. 재타깃을 먼저 하시거나, 넣는 분이 --delete-branch 를 빼 주시면 됩니다.
나머지 — 이견 없습니다
- 새 코드를 만든 판단. 후보 셋이 각각 어떤 행동을 시키는지로 가른 표가 정확합니다.
AI_SERVICE_UNAVAILABLE이 이 이슈 자체이고,DOCUMENT_UNPROCESSABLE은 올린 사람이 할 게 없고,INTERNAL_ERROR는 재시도로 안 고쳐집니다. - 404 와
DOCUMENT_ACCESS_DENIED를 한 타입에 둔 것. "다음 행동이 같다" 가 기준이고DOCUMENT_UNPROCESSABLE의 선례와 같습니다. 문면이 어느 쪽인지 가르는 것도 확인했습니다. AiServiceException을 상속한 것. 갈래를 늘리는 변경이 옛 호출부에서 500 으로 안 새는 것 — 이게 없으면routes.py:108이 적어 둔 그 사고(새 하위 타입이 아무 절에도 안 걸려 500)의 자바판이 됩니다.- 상태 502. ai-service 쪽
_REFUSAL_RESPONSE표(routes.py:134)가 같은 이유로 4xx 를 안 쓰고, 그 판단과 짝이 맞습니다. - 응답에 경로를 안 실은 것.
#582규약과 같고 변이 5로 재집니다. - 이슈 3번(
/ops/status카드)을 미룬 것. 그 카드가/healthz왕복을 재는데 이 갈래는 파스 호출에서만 난다는 근거가 맞습니다.#568의 추출 카드 쪽이 맞는 자리로 보입니다.
1·2 는 문면이고 3 은 머지 순서라, 코드에 이견이 없어서 코멘트로 둡니다. 1번만 갈라 적어 주시면 승인하겠습니다.
junseo2323
left a comment
There was a problem hiding this comment.
web 두 파일(제 영역) 승인합니다. 새 코드가 여섯 벌에 다 들어갔고 문면 규칙도 지킵니다. 그리고 제가 의심한 자리 하나가 이 PR 이 맞는 것으로 나왔습니다 — 아래 ①에 적습니다. 남은 것은 ②(값싼 두 줄)와 ③(이 PR 밖, 이슈 감)입니다.
web — 확인한 것
types.ts 유니온 + `// 502` 주석 → webUnionStatusesMatchContract 가 읽는 형식과 같다
errorText.ts 전체 맵이라 유니온에 값이 늘면 tsc 가 문다 — 이 줄이 그 자리를 메운다
문면 규칙 둘 내부 상태 없음 ✓ / 다음에 할 일 있음 ✓ (「담당자에게 문의」)
FORBIDDEN 이 같은 뒷문장("담당자에게 문의해 주세요")인데 앞이 갈라 줍니다 — «볼 권한이 없어요» 는 차단이고 «문서 파일을 찾지 못했어요» 는 장애라, 화면에서 섞이지 않습니다. 다시 올리라고 안 한 것이 이 코드의 요점이고 그대로 지켜져 있습니다.
S03_Interview.tsx:823 의 describe() 는 자기 switch 를 갖고 있는데 default 가 받으므로 안 깨집니다. 애초에 이 코드가 그 화면에 닿지도 않습니다(업로드·재추출 경로).
① ❗「404 를 parse 전용으로」가 완전한지 재 봤습니다 — 맞습니다
본문의 근거가 "다른 내부 경로의 404 는 «라우트가 없다»" 인데, 저는 문서에 닿는 엔드포인트가 하나 더 있으면 그쪽이 빈다고 봤습니다. /internal/extract 가 failure() 를 쓰니까요(AiServiceClient:552).
안 빕니다. routes.py:252 의 extract 는 body.parsed_document 를 받습니다 — 파일을 안 엽니다. parsing.ParseRefused 계열(DocumentNotFound·DocumentAccessDenied)이 그 라우트에서 나올 수 없습니다.
/internal/parse document_path 로 파일을 연다 ← 닿지 못함이 나는 유일한 자리
/internal/extract parsed_document(이미 판 것) ← 파일에 안 닿는다
그래서 404 를 parseFailure() 안에 둔 것이 좁은 게 아니라 정확합니다. DOCUMENT_ACCESS_DENIED 를 공용 raise() 에 둔 비대칭도 같은 이유로 무해합니다(낼 수 있는 라우트가 하나뿐).
② GlobalExceptionHandler 의 「네 벌」 두 줄 — 여기서 같이 걷어 주세요
:104 "코드 목록은 네 벌 대조 대상이라 늘리는 값이 싸지 않다"
:116 "네 벌(핸들러·openapi·CLAUDE.md·web/src/api/types.ts)"
#586 리뷰에서 @yoonjiseok 님이 ⓐ로 든 자리고, 이슈로 떼기로 정리된 것으로 압니다. 그런데 #586 이 그 수를 여섯으로 만들고, 이 PR 이 실제로 새 코드를 하나 만들면서 바로 그 파일에 핸들러를 더합니다. 그 괄호를 체크리스트로 쓰는 사람은 이제 둘을 빠뜨리고 CI 에서 두 번 되돌아옵니다 — #571 이 없애려던 그 왕복입니다.
두 줄이고 이 PR 이 이미 여는 파일입니다. everyDocumentClaimsTheRightNumberOfCopies 는 마크다운 셋만 보므로 자바 주석을 고쳐도 아무것도 안 깨집니다. 막지는 않겠습니다 — 이슈로 가도 이견 없습니다.
③ ❗/internal/parse 자신의 「라우트가 없다」 404 도 같은 갈래로 떨어집니다 (이 PR 밖)
본문이 «다른 엔드포인트의 404» 는 갈랐는데, 같은 엔드포인트의 두 가지 404 는 안 갈립니다.
DocumentNotFound 404 · detail = 평문 문자열 _REFUSAL_RESPONSE:125 (code 없음)
라우트가 없다 404 · detail = "Not Found" FastAPI 기본
둘 다 평문 detail 이라 본문으로 구별이 안 됩니다. 이미지 세대가 갈려 그 라우트가 없는 배포에서 운영자가 받는 문면이 "업로드 볼륨의 마운트와 소유권을 확인하세요" 입니다 — 고칠 자리가 배포인 것은 맞아서 오진의 값이 크진 않은데, 문면이 볼륨을 콕 집습니다. 세대 어긋남은 이 팀이 실제로 한 번 밟은 사고입니다(핫재기동 뒤 /internal/score 422).
값싼 닫음은 ai-service 쪽입니다 — DocumentNotFound 에도 본문 코드를 주면((404, None) → (404, "DOCUMENT_NOT_FOUND")) DOCUMENT_ACCESS_DENIED 와 같은 모양이 되고 상태에 안 기댑니다. 그 표의 주석이 스스로 든 근거가 그것입니다: "상태 코드로는 이 사실을 말할 수 없으므로 본문에 코드를 싣는다." 계약이라 이 PR 에서 할 일은 아니고 @yoonjiseok 님 판단입니다. 원하시면 제가 이슈로 떼겠습니다.
머지 순서
base 가 test/error-code-sixth-copy-spec 입니다. #586 은 방금 제가 승인했으니 그것부터 들어가면 base 를 main 으로 돌리시면 됩니다. #585 가 조금 전 머지돼 errorText.ts 를 건드렸는데 파일 머리 주석(:21~28)이라 이 PR 의 표(:53~56)와 자리가 떨어져 있습니다 — 충돌은 안 날 자리인데 rebase 때 한 번 봐 주세요.
|
부모(
|
|
base 재타깃 확인했습니다 — diff 가 이 PR 것만 남았고 앞 리뷰의 1·2 는 아직 답을 못 받았습니다. 초록이라 지금 머지될 수 있는 상태라 한 번만 더 짚습니다.
둘 다 문면이라 막지 않았습니다. 이슈로 떼실 거면 그것도 좋습니다 — 다만 어느 쪽인지만 알려 주시면 제가 뒤에 안 걷어도 됩니다. |
리뷰가 400 갈래를 다시 셌다. `DocumentPathRejected` 가 나는 자리가 다섯인데 내 표는 「우리 배선 버그」 하나로 적었다. parsing.py:682·684·693·696 경로가 비었다 · NUL · 해소 실패 · 뿌리 밖 배선 버그 ✓ parsing.py:749 수동 파스 출력의 product_type 이 요청과 다르다 ❗데이터 상태 다섯째는 운영자가 실제로 만들 수 있는 상태(#441 의 JSON)이고 고칠 자리는 그 파일인데, 지금 문면은 AI_SERVICE_UNAVAILABLE 이라 ai-service 를 가리킨다 — 이 PR 이 없애려는 그 고리 그대로다. 여기서 못 고치는 이유는 구별할 재료가 안 오는 것이다: ai-service 의 거부 표가 그 갈래에 body code 를 안 싣는다(`(400, None)`). 그래서 서버는 넷과 다섯째를 구별할 방법이 없다. 그 사실을 `raise()` javadoc 에 적었다 — #556 이 「넷 중 셋이 뭉친다」로 열렸고 이 PR 이 둘을 갈랐으니, 하나가 남는다는 것이 코드에 있어야 다음 사람이 그것을 찾는다. DOCUMENT_UNREACHABLE 로 보내지 않는다는 것도 같이 적었다 — 볼륨을 보라고 하는데 볼륨은 멀쩡하다. ## 삭제한 메서드를 가리키던 링크 `{@link #errorCode}` 가 남아 있었다 — 이 PR 이 지운 메서드다. 같은 이유가 `raise()` 안에 그대로 있어서 그쪽으로 돌렸다. 파일에 `errorCode` 언급이 0건이 됐다. `javadoc` 이 이걸 안 잡는다는 것도 확인했다 — private 이라 기본 범위 밖이고 그 태스크가 CI 에 없다. `./gradlew test` 821건 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
셋 다 처리했습니다( 1. 400 갈래 — 갈라 적고 이슈로 뗐습니다다섯 자리를 제가 다시 셌고 지적대로입니다. 본문 표만 고치지 않고 이슈로 떼는 것은 제가 했습니다 — #598. 선행이 2. 삭제한 메서드 링크
3. 머지 순서 — 이미 재타깃했습니다
|
gitIt-sehyeon
left a comment
There was a problem hiding this comment.
승인합니다. 셋 다 확인했고, 1번을 PR 본문이 아니라 raise() javadoc 에 적으신 것이 제가 요청한 것보다 낫습니다.
errorCode 언급 0건 ({@link #raise} 로 갔다)
raise() javadoc 「아직 뭉쳐 있는 갈래가 하나 남는다」 절 신설
base main · 합친 결과로 821건 통과 (재확인)
이슈 #598 · 담당 윤지석·강희진
PR 본문은 히스토리로 내려가고, 다음 사람이 «넷 중 하나가 남는다» 를 찾을 자리는 코드입니다.
맞습니다. 제가 «표에 한 줄» 로 요청했는데 그건 찾을 수 없는 자리에 적는 것이었습니다. 그리고 새 절이 제가 낸 것보다 한 칸 더 갑니다 — 왜 여기서 못 고치는지(ai-service 표가 (400, None) 이라 구별할 재료가 안 온다)와 DOCUMENT_UNREACHABLE 이 아닌 이유(볼륨은 멀쩡하다)가 같이 있어서, 이 갈래를 집어 드는 사람이 잘못된 코드로 보내는 것부터 막힙니다.
셈도 그쪽이 맞습니다 — 다섯 자리 중 넷이 배선 버그입니다. 제 리뷰 머리글이 「네 자리 중」이었는데 표에 다섯을 적어 놓고 문장만 하나 빠뜨렸습니다.
#598 에 routes.py 표를 선행으로 걸고 @yoonjiseok 님과 공동 담당으로 두신 것도 맞습니다 — 그 표가 안 고쳐지면 서버 쪽에서 할 수 있는 게 없습니다.
2·3 도 확인
{@link #raise} 로 간 자리에 같은 근거(path() vs get() · #293)가 그대로 있어서 링크가 실제로 답을 가리킵니다. base 는 main 이고 합친 결과가 821건입니다.
7·8·9 가 제가 안 재본 방향입니다.
리팩터링이 방어를 옮길 때만 생기는 자리라, 새 기능을 재는 변이로는 안 나옵니다. 이 PR 은 errorCode() 를 지우면서 #293 이 세운 것을 raise() 로 이동시켰는데, 그때 확인할 것이 「새 갈래가 도는가」가 아니라 「옛 그물이 옮긴 뒤에도 무는가」 입니다.
yoonjiseok
left a comment
There was a problem hiding this comment.
승인합니다. /internal/parse 거부 표를 만든 쪽(#551)으로서 봤고, 수신 매핑이 제 표와 정확히 맞습니다. 그리고 이 PR 이 #571·#586 이후 처음 만들어지는 에러 코드라 그 두 PR 의 값이 여기서 처음 실전에 걸립니다 — 그것도 재봤습니다.
제 표와 대조
ai-service _REFUSAL_RESPONSE 이 PR 의 수신
DocumentPathRejected (400, None) 502 AI_SERVICE_UNAVAILABLE 그대로 ❗아래
DocumentNotFound (404, None) 502 DOCUMENT_UNREACHABLE ◀
DocumentUnreadable (422, None) 400 DOCUMENT_UNPROCESSABLE 그대로
DocumentAccessDenied (502, "DOCUMENT_ACCESS_DENIED") 502 DOCUMENT_UNREACHABLE ◀
404 와 DOCUMENT_ACCESS_DENIED 를 한 코드로 묶은 판단이 맞습니다 — "다음 행동이 같아서" 가 정확한 기준이고, 그게 DOCUMENT_UNPROCESSABLE 이 «못 열림»과 «PII 거부»를 한 코드로 낸 것과 같은 결입니다. 제 쪽에서 그 둘을 갈라 둔 이유는 고치는 자리가 달라서인데(업로드·마운트 ↔ 볼륨 소유권), 운영자가 볼 곳은 둘 다 업로드 볼륨이라 서버 층에서 합치는 게 맞습니다.
502 로 둔 근거도 제 쪽과 같습니다 — 요청도 문서도 정상이고 못 읽는 것이 우리 배포라 4xx 면 호출자 잘못으로 읽힙니다.
❗#571·#586 이 여기서 처음 값을 냅니다
DOCUMENT_UNREACHABLE 이 그 두 PR 이후 처음 생기는 코드입니다. 여섯 벌이 다 차 있고, 그중 예전에는 ./gradlew test 가 못 잡던 셋을 각각 빼서 재봤습니다.
web/src/lib/errorText.ts 에서 뺀다 10 tests, 1 failed ← #571 이 넣은 다섯째
docs/functional-spec-v1.2.md 에서 뺀다 10 tests, 2 failed ← #586 이 넣은 여섯째
web/src/api/types.ts 에서 뺀다 10 tests, 1 failed
복원 BUILD SUCCESSFUL
#571 전이었으면 앞의 것은 CI 의 web 스텝에서만 빨개졌고(#527 브랜치에서 5분 뒤 고침 커밋이 붙은 그 자리), #586 전이었으면 명세서 §9 는 아무도 안 잡았습니다. 지금은 서버 테스트 한 번에 여섯이 다 나옵니다 — 그 두 PR 이 노린 것이 이것이고, 이번이 첫 실물입니다.
참고로 그래서
npm run build를 따로 안 돌렸습니다. CLAUDE.md 가 "코드를 더했으면npm run build까지 돌린다" 로 적어 둔 이유가 «다섯째는 tsc 만 잡는다» 였는데,#571이후로는 그 문장의 전제가 바뀌었습니다. 그 문단도 언젠가 같이 손봐야 할 것 같습니다.
「본문을 한 번만 읽는다」가 구조로 간 것
resp.getBody()는 스트림이라 두 번째 읽기가 빈 값을 주고 …parseFailure가 그 함정을 이미 한 번 밟았습니다(스키마 거부가DocumentUnreadable로 샜습니다).
읽는 자리를 failure() 하나로 올려 다시 밟을 수 없게 만든 것이 좋습니다. 규약으로 적는 것과 구조로 못 하게 하는 것이 다른 층인데 뒤쪽입니다.
역검증에서 세 번째가 처음에 초록이었다는 것도 좋습니다 — "javadoc 이 근거로 든 구분을 코드가 안 지키고 있었는데 재는 것이 없었다". 「404 를 공용 갈래에 두지 않는다」가 이 PR 의 판단인데 그 판단을 무는 것이 없던 자리고, /internal/score 404 단정으로 닫으셨습니다. 저는 어제 #593 에서 정확히 같은 것을 놓쳤습니다(본문이 근거로 든 갈래를 그물에 안 걸었습니다).
#598 — 제 몫이라 모양을 적어 둡니다
ai-service 의 거부 표가 그 갈래에 body code 를 안 싣습니다(
(400, None))라 서버가 넷과 다섯째를 구별할 재료가 없습니다.
맞습니다. 그리고 표가 예외 타입으로 키를 잡습니다.
_REFUSAL_RESPONSE: dict[type[parsing.ParseRefused], tuple[int, str | None]]다섯째(_manual_override 의 product_type 불일치)가 같은 DocumentPathRejected 를 던지므로, 코드를 붙이려면 하위 타입을 하나 만들어야 합니다. 그러면 자연히 갈립니다.
❗그리고 그걸 잊을 수가 없습니다 — #551 이 넣은 _assert_every_refusal_is_mapped 가 ParseRefused 하위를 재귀로 훑어서 미매핑이면 기동에서 죽습니다. 새 하위를 만들고 표에 안 넣으면 앱이 import 조차 안 됩니다.
즉 #598 은 「타입 하나 + 표 한 줄 + 문면」이고 순서는 제 쪽이 먼저입니다. 이 PR 이 그 사실을 raise() javadoc 에 적어 두신 것도 맞습니다 — 지금 상태가 「아직 뭉쳐 있다」는 것이 코드 옆에 남아야 다음 사람이 «넷 다 갈렸다» 로 안 읽습니다.
확인한 것
server 전건 (PR 브랜치 · JDK 17) BUILD SUCCESSFUL (35s)
ErrorCodeContractTest · AiServiceClientTest ·
DocumentUploadWiringTest BUILD SUCCESSFUL
여섯 벌에 DOCUMENT_UNREACHABLE 전부 있음 · 셋 빼기 변이 모두 빨강
수신 매핑 ↔ ai-service _REFUSAL_RESPONSE 일치
base main (c3d8cfa) — #586 머지 후 재타깃 확인
DocumentUnreachableException 이 AiServiceException 을 상속해서 이 타입을 모르는 옛 호출부가 지금처럼 502 로 다뤄지는 것 — 갈래를 늘리는 변경이 조용히 500 을 만들지 않게 한 것도 좋습니다.
합친 결과로 재검증: ./gradlew test 822건 통과 (#591 포함 후)
#556입니다.#548·#551이 머지돼서 조건이 찼습니다.Note
base 를
main으로 돌렸습니다 — 부모(#586)가 머지됐습니다. 원래는 이렇게 쌓여 있었습니다:#586위에 쌓았습니다. 새 에러 코드가 명세서 §9 의 목록 줄을 건드리는데#586이같은 줄을 고칩니다(「다섯 벌」→「여섯 벌」). 그 위가 아니면 충돌합니다.
#586이 머지되면여기 base 를
main으로 돌립니다.무엇이 문제였나
ai-service 는 거부를 넷으로 갈라 주는데
raise()가 셋을 하나로 뭉쳤습니다. 상태와본문 코드가 둘 다 손에 있는데 예외 타입이 그것으로 갈리지 않았습니다.
❗400 은 「배선 버그」가 아닌 것이 하나 섞여 있습니다 (리뷰 지적)
DocumentPathRejected가 나는 자리가 다섯인데 성격이 갈립니다.다섯째는 운영자가 실제로 만들 수 있는 상태(
#441의 JSON)이고 고칠 자리는 그 파일인데,지금 문면은 ai-service 를 가리킵니다 — 이 PR 이 없애려는 그 고리입니다.
여기서 못 고칩니다 — ai-service 의 거부 표가 그 갈래에 body code 를 안 싣습니다
(
(400, None))라 서버가 넷과 다섯째를 구별할 재료가 없습니다. 그 표를 고치는 것이선행이고
routes.py는 남의 파일입니다.raise()javadoc 에 그 사실을 적었고 #598 로뗐습니다.
#532에서 찾은 상태가 정확히 이 자리입니다 —docker volume create가 만든 볼륨은root:root이고 server 는 uid 10001 로 돕니다(결정 7.54).새 코드를 하나 만든 판단
이슈가 "새
ApiError.code가 필요한지 판단한다" 로 남겨 둔 것입니다. 필요합니다 —기존 코드로는 셋 다 틀린 안내를 합니다.
AI_SERVICE_UNAVAILABLEDOCUMENT_UNPROCESSABLEINTERNAL_ERROR404 와
DOCUMENT_ACCESS_DENIED는 다음 행동이 같아서(배포에서 업로드 볼륨을 본다)한 타입·한 코드입니다 —
DOCUMENT_UNPROCESSABLE이 «못 열림» 과 «PII 거부» 를 한 코드로낸 것과 같은 판단이고, 문면이 어느 쪽인지 가릅니다.
상태는 502 입니다. 요청도 문서도 정상이고 못 읽는 것이 우리 배포라 4xx 로 두면 호출자
잘못으로 읽힙니다 — ai-service 가 같은 이유로 이 갈래를 502 에 뒀습니다(그쪽
_REFUSAL_RESPONSE주석).❗본문을 한 번만 읽는다 — 구조로
raise()가 본문을 인자로 받습니다.resp.getBody()는 스트림이라 두 번째 읽기가 빈값을 주고, 갈래마다 따로 읽으면 앞 갈래가 뒤를 먹습니다 —
parseFailure가 그 함정을 이미한 번 밟았습니다(스키마 거부가
DocumentUnreadable로 샜습니다). 읽는 자리를failure()하나로 올려 그 실수가 안 나게 했고, 쓰이지 않게 된errorCode()를 지웠습니다.❗404 를 공용 갈래에 두지 않는다
/internal/parse의 404 만 「문서에 닿지 못했다」입니다. 다른 내부 경로의 404 는 「그 라우트가없다」이고, 그걸 볼륨 탓으로 읽으면 오진의 방향만 바뀝니다.
역검증 — 하나가 처음엔 초록이었습니다
세 번째가 통과했습니다. javadoc 이 근거로 든 구분을 코드가 안 지키고 있었는데 재는 것이
없었습니다.
/internal/score404 가AiServiceException으로 남는 단정을 넣었습니다.바꾼 것
core/aiservice/DocumentUnreachableException.java(신설) —AiServiceException을상속해서, 이 타입을 모르는 옛 호출부는 지금처럼 502 로 다룹니다(갈래를 늘리는 변경이
조용히 500 을 만들지 않습니다).
core/aiservice/AiServiceClient.java—raise()를 상태·본문 코드로 가르고 본문을인자로 받게.
parseFailure()가 404 를 그 자리에서 가릅니다.api/exception/GlobalExceptionHandler.java— 502DOCUMENT_UNREACHABLE. 응답 본문에저장 경로를 안 싣습니다(
#582와 같은 규약) — 어디를 봐야 하는지만 적고 경로는 로그로.types.ts·errorText.ts·명세서 §9(14종 → 15종 · 요약표). 문면 표에 「다시 올려라」를 안 적었습니다.
재추출이 502
DOCUMENT_UNREACHABLE로 나가고 「채점 서비스」도 경로도 안 실린다.이슈의 3번은 안 했습니다
"
/ops/status의 ai-service 카드가 이 구별을 쓸 수 있는지 본다" — 그 카드는/healthz왕복을 재고, 이 갈래는 파스 호출에서만 납니다. 카드가 이 사실을 쓰려면 「최근 실패의
원인」을 들고 있어야 하는데 그건
/ops/status의 성격(지금 상태)과 다릅니다.#568이제안한 추출 카드 쪽에 더 가까워 보여서 그 판단은 그 이슈로 넘깁니다.
검증
./gradlew test821건 통과(+5) ·npm run build통과.@gitIt-sehyeon @yoonjiseok (
routes.py표의 소비자가 이 코드입니다) @junseo2323