fix(S15P11A705-121): 외부 API 재시도와 오류 분류를 명세에 맞춘다 - #44
Merged
Conversation
두 클라이언트가 상태 코드를 정반대로 분류하고 있었다. embedding 은 `>= 500` 만 Transient 로 보아 429 한 번에 Context 가 영구 실패했고, llm 은 모든 non-200 을 Transient 로 보아 인증 실패가 재스캔 주기마다 GMS 호출을 만들었다. 각자 상태 코드 표를 들고 있던 것이 원인이라 매핑을 errors.py 의 classify_http_status 한 곳으로 모았다 — spec §2 가 분류 지점으로 지목한 파일이다. 429 를 `>= 500` 보다 먼저 판정해야 4xx 로 떨어지지 않는다. 재시도는 retry.py 에 RetryPolicy + call_with_retry 로 넣었다. 총 3회 시도, 지수 백오프 0.5→1.0s(상한 4.0s) + full jitter. 재시도 대상은 TransientError 하나다 — §3.1 의 대상 목록이 §2.1 의 Transient 집합과 같으므로 재시도용 상태 코드 표를 따로 두지 않았다. 표가 둘이면 갈라지고, 갈라진 결과가 위 오분류였다. 백오프 값은 Settings 로 열지 않았다. §3.2 의 상한(두 타임아웃 합 + 재시도 < 600s)에 묶인 값이라 env 로 열면 상한이 배포마다 달라진다. 현재 최악값 453s 이고 그 부등식을 테스트가 지킨다. 대신 RetryPolicy 를 생성자 인자로 받아 테스트가 sleep·jitter 를 주입한다. 구조화 출력 위반은 SchemaViolationError(TransientError) 로 두어 재시도 중에는 Transient 로 동작하고 소진 시 judge 가 PermanentError 로 승격한다. LLM 출력은 비결정론적이라 재요청에 성공 여지가 있고, 승격을 client 안에서 끝내야 service 가 보는 분류가 두 종류로 유지된다. 하위 타입인 채 새면 except TransientError 가 먼저 잡아 무한 재판정이 된다. keyword_service 에는 PermanentError 핸들러가 아예 없었다. llm_client 만 고치면 401 이 BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다 — 고치려던 무한 재시도가 경로만 바꿔 남는다. PermanentError → _fail() 결선을 추가하고, 로그 레벨을 일시 WARN·영구 ERROR 로 명세에 맞췄다. 상태 전이 규칙은 바꾸지 않았다. fakes.py 의 raise_exc 가 어느 테스트에서도 쓰이지 않아 Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없던 것이 오분류를 놓친 직접 원인이다. 그 경로를 실제로 주입하고, client HTTP 계층 테스트를 신설했다. 후자는 integration-tests §5 계층 밖이며 §4.2 는 파이프라인이 client 를 무엇으로 대체하는가의 규칙이라 인터페이스 Fake 로는 상태 코드 매핑을 볼 수 없다. 예외 근거는 tests/README.md 에 적었고 spec 은 고치지 않았다. 74 passed → 131 passed, ruff exit 0. Circuit Breaker 와 타임아웃 60/90 재산정은 티켓 제외 범위다. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
resp.json() 자체가 실패하는 분기가 미커버였다. GMS 가 200 으로 HTML 오류 페이지를 돌려주는 실제 사례이며, 이 경로가 SchemaViolationError 로 분류되지 않으면 분류되지 않은 예외로 새어 단계가 PROCESSING 에 머문다. llm_client 커버리지 97% → 100%. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
계약이 WORKLOG 를 금지 범위로 둔 이유는 당시 ai#41 이 같은 파일을 건드리고 있었기 때문이다. 그 PR 은 CLOSED 되고 대체 PR(ai#42)이 병합되어 전제가 사라졌음을 중앙이 확인했으므로 한 줄을 넣는다. integration-tests §4.2 의 계층 구분 명문화를 남은 것으로 옮겼다. 중앙과 "층이 다르므로 충돌이 아니다" 로 합의했고 근거는 tests/README 에 있으나, 명세가 그 구분을 담지 않는 한 같은 의문이 다시 나온다. docs/spec/ 수정은 이 티켓의 금지 범위라 별건이다. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
colosair
force-pushed
the
fix/S15P11A705-121-retry-and-error-classification
branch
from
July 30, 2026 02:44
87ffe07 to
5c513a9
Compare
colosair
marked this pull request as ready for review
July 30, 2026 02:45
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
두 클라이언트가 HTTP 상태 코드를 정반대로 분류하고 있었다.
429는 spec이 Transient인데 구현은 Permanent여서 rate limit 한 번에 Context가 영구 실패했고, LLM400·401·403은 spec이 Permanent인데 구현은 Transient여서 인증 실패가 재스캔 주기(5분)마다 GMS 호출을 만들었다. 여기에 §3.1의 짧은 재시도가 아예 없었다. 구현을docs/spec/failure-recovery.md에 맞춘다 — 명세는 수정하지 않았다.DB·스키마 변경 없음. 외부 계약(API 형태·상태 전이 규칙) 변경 없음.
Jira (필수)
test_transient_status_codes·test_embedding_{429,5xx,timeout,connect_error}_*test_permanent_status_codes·test_llm_4xx_is_permanent_and_not_retriedtest_transient_is_retried_up_to_two_times(3회 호출)test_backoff_is_exponential_and_capped·test_full_jitter_stays_within_backoffRetryPolicy.sleep·jitter필드. 모든 재시도 테스트가 주입 — 실제 sleep 없음test_llm_schema_violation_is_retried_then_permanent(3회 후PermanentError)test_embedding_transient_keeps_processing·test_judge_transient_keeps_keyword_processingraise_exc등 Fake 오류 경로 실제 사용test_pipeline.py신규 6건이raise_exc사용 (기존 grep 0건)dev@19a5d21baseline 88 + 신규 58)failure-recovery.md일치변경 사항
app/core/errors.py—classify_http_status신설. spec §2가 "errors.py에서 분류한다"고 지목한 지점에 상태 코드 표를 하나만 둔다. 두 클라이언트가 각자 표를 들고 있던 것이 오분류의 원인이다.429를>= 500보다 먼저 판정해야 4xx로 떨어지지 않는다.SchemaViolationError(TransientError)도 추가 — 하위 타입인 이유는 리뷰 포인트 2.app/client/retry.py(신규) —RetryPolicy(총 3회·0.5→1.0s·상한4.0s·full jitter) +call_with_retry. 재시도 대상은TransientError하나다.app/client/embedding_client.py— 분류를classify_http_status에 위임. 재시도는 배치 1건 = API 호출 1회 단위(성공한 앞 배치를 재전송하지 않음). 응답 형식 위반을PermanentError로 분류(기존엔 분류되지 않은 예외로 샜다).app/client/llm_client.py— 분류 위임.judge를_judge_once+ 재시도로 분리. 구조화 출력 위반은 재시도하되 소진 시PermanentError로 승격.app/service/keyword_service.py—PermanentError핸들러 추가(없었다). 상세는 리뷰 포인트 1.app/service/embedding_service.py— 로그 레벨을 §2.1(WARN)·§2.2(ERROR)에 맞추고stage를 포함. 동작 변경 없음.tests/test_client_retry.py(신규) — client HTTP 계층. 리뷰 포인트 3.tests/test_unit.py— 상태 코드 표 대조, 백오프 수열, §3.2 예산 부등식.tests/test_pipeline.py—raise_exc로 4경로 + CANCELLED 경합 2건.tests/README.md·docs/implements/— 계층 예외 근거와 구현 리포트.테스트 / 검증
RED
app/service/keyword_service.py의PermanentError핸들러만 되돌리고(git stash push -- app/service/keyword_service.py)pytest tests/test_pipeline.py -k judge_permanent→ 1 failed. 실패 형태가app.core.errors.PermanentError: llm error: 401이 파이프라인 밖으로 새는 것으로, 예측한 경로와 같다.raise_exc는 착수 시점에grep -rn raise_exc tests결과가fakes.py정의 4행뿐이었다(사용 0건) — 오류 경로가 한 번도 실행된 적 없음.GREEN
pytest tests/test_client_retry.py→ 31 passedpytest tests/test_pipeline.py→ 25 passed (신규 6 포함)pytest tests/test_unit.py→ 43 passed (신규 11 포함)Regression
ruff check .→ exit 0, "All checks passed!"python -m compileall app tools→ exit 0pytest --cov=app --cov-branch --cov-report=term-missing→ 146 passed, TOTAL 88%. 변경 모듈:retry.py100% ·errors.py100% ·llm_client.py100% ·embedding_client.py96%(미커버 2행은 기존preset_embed_text)dev@19a5d21. 착수 시dev@518b2bfbaseline이 74였고,ai#42의 parity 테스트 14건이 합류해 새 baseline은 88이다(88 + 58 = 146). rebase는 충돌 없이 끝났다 —ai#42가 건드린 4개 파일과 이 PR의 변경 파일이 겹치지 않는다.리뷰 포인트
keyword_service.py가 이 PR 범위인 근거.judge호출은except TransientError만 잡고PermanentError핸들러가 없었다. 위쪽도 무방비다 —context_processing에 광범위 except 없음,context.py는BackgroundTasks.add_task로 넘김. 그래서llm_client만 고치면 401이 BackgroundTasks까지 새어 트레이스백만 남기고 단계는 PROCESSING에 머문다 → 만료 후 재스캔이 같은 호출을 반복한다. 즉 고치려던 무한 재시도가 경로만 바꿔 그대로 남는다.embedding_service는 이 결선을 이미 갖고 있어 비대칭이었고, 그쪽만 맞췄다. 상태 전이 규칙 자체는 바꾸지 않았다.SchemaViolationError를TransientError의 하위 타입으로 둔 것. spec §2는 "두 종류"만 규정한다. 하위 타입인 이유는 §2.2의 "재시도 후에도 스키마 위반"이 도달 가능해야 하기 때문이다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있다. 승격은judge안에서 끝나므로 service가 보는 분류는 여전히 두 종류다. 하위 타입인 채 새면except TransientError가 먼저 잡아 무한 재판정이 되므로, 그것을test_schema_violation_does_not_escape_as_transient가 막는다. Embedding 응답 형식 위반은 이 타입을 쓰지 않고 곧바로 Permanent다(프로바이더가 같은 형식으로 답하므로 재시도가 무의미).tests/test_client_retry.py가integration-tests.md§4.2와 충돌하는지. §4.2는 "HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"라고 적는데 이 파일은httpx.MockTransport를 쓴다. 판단: §4.2는 파이프라인 시나리오가 client를 무엇으로 대체하는가의 규칙이고, 이 파일이 검증하는 것은 그 대체물이 아니라 실제 client 자신의 HTTP 계층이다. 인터페이스 Fake로는_embed_batch가 429를 어떻게 분류하는지 볼 수 없고, 그 공백이 이번 오분류를 놓친 직접 원인(감사 F-3)이다. 예외 근거를tests/README.md에 기록했고docs/spec/은 고치지 않았다. 이 판단에 이견이 있으면 spec 개정 티켓으로 올리는 편이 맞다고 본다.백오프 값을
Settings(환경변수)로 열지 않은 것. §3.2의 상한("두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s")에 묶인 값이라 env로 열면 그 상한이 배포마다 달라진다. 현재 최악값은3 × (60 + 90) + 2 × 1.5 = 453s이고, 이 부등식 자체를test_retry_budget_fits_processing_expiry가 지킨다. 운영 튜닝이 필요하다는 판단이면 되돌릴 수 있다.리스크
back은ai.context_ai_state를 그대로 읽는다. 다만 오분류 정정 자체가 관측 가능한 동작 변경이다 — 429였던 Context가 이제 FAILED로 종결되지 않고 재스캔 대상으로 남고, LLM 인증 실패는 이제 FAILED로 종결된다. 후자는 재스캔 Scheduler(주기 5분·최대 3회)의 무한 호출을 없애는 것이 목적이다.범위 밖 / 후속
integration-tests.md§4.2의 계층 구분 명문화 — 후속 별건. §4.2는 파이프라인 시나리오와 client 단위 두 층을 구분하지 않아 리뷰 포인트 3과 같은 의문이 다시 나올 수 있다. 층이 다르므로 충돌은 아니라는 판단은 합의됐고 근거를tests/README.md에 적었으나,docs/spec/수정은 이 티켓의 금지 범위여서 손대지 않았다.→ 반영 완료(docs/WORKLOG.md미갱신5c513a9). 계약이 금지 범위로 둔 이유는 당시ai#41이 같은 파일을 건드리고 있었기 때문이며, 그 PR이 CLOSED되고 대체 PR(ai#42)이 병합되어 전제가 사라졌다.영구 문서
docs/implements/2026-07-30-retry-and-error-classification.md(신규) +docs/implements/README.md목록 등록docs/WORKLOG.md— 작업 이력 한 줄tests/README.md— client HTTP 계층 예외와raise_exc사용 규칙docs/spec/변경 없음 — 이 작업은 구현을 명세에 맞추는 것이다관련 GitHub Issue (선택)