Skip to content

fix(S15P11A705-121): 외부 API 재시도와 오류 분류를 명세에 맞춘다 - #44

Merged
colosair merged 3 commits into
devfrom
fix/S15P11A705-121-retry-and-error-classification
Jul 30, 2026
Merged

fix(S15P11A705-121): 외부 API 재시도와 오류 분류를 명세에 맞춘다#44
colosair merged 3 commits into
devfrom
fix/S15P11A705-121-retry-and-error-classification

Conversation

@colosair

@colosair colosair commented Jul 30, 2026

Copy link
Copy Markdown
Member

요약

두 클라이언트가 HTTP 상태 코드를 정반대로 분류하고 있었다. 429는 spec이 Transient인데 구현은 Permanent여서 rate limit 한 번에 Context가 영구 실패했고, LLM 400·401·403은 spec이 Permanent인데 구현은 Transient여서 인증 실패가 재스캔 주기(5분)마다 GMS 호출을 만들었다. 여기에 §3.1의 짧은 재시도가 아예 없었다. 구현을 docs/spec/failure-recovery.md에 맞춘다 — 명세는 수정하지 않았다.

DB·스키마 변경 없음. 외부 계약(API 형태·상태 전이 규칙) 변경 없음.

Jira (필수)

  • 키 또는 URL: S15P11A705-121
  • 완료 조건: 10개 전부 충족. 아래 대조표 참조.
완료 조건 충족 증거
timeout·429·5xx만 transient retry 대상 test_transient_status_codes · test_embedding_{429,5xx,timeout,connect_error}_*
400·401·403은 permanent test_permanent_status_codes · test_llm_4xx_is_permanent_and_not_retried
최초 호출 이후 최대 2회 재시도 test_transient_is_retried_up_to_two_times (3회 호출)
exponential backoff와 jitter 적용 test_backoff_is_exponential_and_capped · test_full_jitter_stays_within_backoff
backoff·jitter를 테스트에서 주입 가능 RetryPolicy.sleep·jitter 필드. 모든 재시도 테스트가 주입 — 실제 sleep 없음
retry 소진 후 schema violation이 permanent test_llm_schema_violation_is_retried_then_permanent (3회 후 PermanentError)
transient 동안 Context 상태 유지 계약 검증 test_embedding_transient_keeps_processing · test_judge_transient_keeps_keyword_processing
raise_exc 등 Fake 오류 경로 실제 사용 test_pipeline.py 신규 6건이 raise_exc 사용 (기존 grep 0건)
전체 regression 통과 146 passed (dev@19a5d21 baseline 88 + 신규 58)
구현과 failure-recovery.md 일치 §2.1·§2.2·§3.1 전부. 로그 레벨(WARN/ERROR)까지 포함

변경 사항

  • app/core/errors.pyclassify_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.pyPermanentError 핸들러 추가(없었다). 상세는 리뷰 포인트 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.pyraise_exc로 4경로 + CANCELLED 경합 2건.
  • tests/README.md · docs/implements/ — 계층 예외 근거와 구현 리포트.

테스트 / 검증

RED

  • 변경 전 실패 증거: app/service/keyword_service.pyPermanentError 핸들러만 되돌리고(git stash push -- app/service/keyword_service.py) pytest tests/test_pipeline.py -k judge_permanent1 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.py31 passed
  • pytest tests/test_pipeline.py25 passed (신규 6 포함)
  • pytest tests/test_unit.py43 passed (신규 11 포함)

Regression

  • ruff check . → exit 0, "All checks passed!"
  • python -m compileall app tools → exit 0
  • pytest --cov=app --cov-branch --cov-report=term-missing146 passed, TOTAL 88%. 변경 모듈: retry.py 100% · errors.py 100% · llm_client.py 100% · embedding_client.py 96%(미커버 2행은 기존 preset_embed_text)
  • rebase 기준: dev@19a5d21. 착수 시 dev@518b2bf baseline이 74였고, ai#42의 parity 테스트 14건이 합류해 새 baseline은 88이다(88 + 58 = 146). rebase는 충돌 없이 끝났다 — ai#42가 건드린 4개 파일과 이 PR의 변경 파일이 겹치지 않는다.
  • DB 계약 변경 시 pgvector Testcontainers 검증 — DB 계약 변경은 없으나 Docker 29.6.1로 Testcontainers 실 pgvector 포함 전량 실행. 미실행 범위 없음

리뷰 포인트

  1. keyword_service.py가 이 PR 범위인 근거. judge 호출은 except TransientError만 잡고 PermanentError 핸들러가 없었다. 위쪽도 무방비다 — context_processing에 광범위 except 없음, context.pyBackgroundTasks.add_task로 넘김. 그래서 llm_client만 고치면 401이 BackgroundTasks까지 새어 트레이스백만 남기고 단계는 PROCESSING에 머문다 → 만료 후 재스캔이 같은 호출을 반복한다. 즉 고치려던 무한 재시도가 경로만 바꿔 그대로 남는다. embedding_service는 이 결선을 이미 갖고 있어 비대칭이었고, 그쪽만 맞췄다. 상태 전이 규칙 자체는 바꾸지 않았다.

  2. SchemaViolationErrorTransientError의 하위 타입으로 둔 것. spec §2는 "두 종류"만 규정한다. 하위 타입인 이유는 §2.2의 "재시도 후에도 스키마 위반"이 도달 가능해야 하기 때문이다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있다. 승격은 judge 안에서 끝나므로 service가 보는 분류는 여전히 두 종류다. 하위 타입인 채 새면 except TransientError가 먼저 잡아 무한 재판정이 되므로, 그것을 test_schema_violation_does_not_escape_as_transient가 막는다. Embedding 응답 형식 위반은 이 타입을 쓰지 않고 곧바로 Permanent다(프로바이더가 같은 형식으로 답하므로 재시도가 무의미).

  3. tests/test_client_retry.pyintegration-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 개정 티켓으로 올리는 편이 맞다고 본다.

  4. 백오프 값을 Settings(환경변수)로 열지 않은 것. §3.2의 상한("두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s")에 묶인 값이라 env로 열면 그 상한이 배포마다 달라진다. 현재 최악값은 3 × (60 + 90) + 2 × 1.5 = 453s이고, 이 부등식 자체를 test_retry_budget_fits_processing_expiry가 지킨다. 운영 튜닝이 필요하다는 판단이면 되돌릴 수 있다.

리스크

  • 계약: 상태 전이 규칙·API 형태·DB 스키마 변경 없음. backai.context_ai_state를 그대로 읽는다. 다만 오분류 정정 자체가 관측 가능한 동작 변경이다 — 429였던 Context가 이제 FAILED로 종결되지 않고 재스캔 대상으로 남고, LLM 인증 실패는 이제 FAILED로 종결된다. 후자는 재스캔 Scheduler(주기 5분·최대 3회)의 무한 호출을 없애는 것이 목적이다.
  • 데이터·개인정보: 없음. 오류 메시지는 기존과 같이 응답 본문 앞 200자만 싣는다(범위 확대 없음).
  • 운영·배포: 재시도 도입으로 단일 요청의 최악 소요가 150s → 453s가 된다. PROCESSING 만료 600s 안이지만 여유가 줄었다. 타임아웃 60/90 재산정은 티켓 제외 범위이며, 값이 바뀌면 위 부등식 테스트가 먼저 깨진다.

범위 밖 / 후속

  • Circuit Breaker (spec §2.2 말미) — 티켓 제외 범위, MVP 밖으로 유예. 인증 실패처럼 전 서비스에 영향을 주는 오류를 개별 Context의 FAILED로 누적시키는 문제는 남는다.
  • 연결·읽기 타임아웃 분리와 60/90 재산정 (§3.2, 감사 A-5) — 티켓 제외 범위. 별건.
  • 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 (선택)

  • 없음

colosair and others added 3 commits July 30, 2026 11:42
두 클라이언트가 상태 코드를 정반대로 분류하고 있었다. 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
colosair force-pushed the fix/S15P11A705-121-retry-and-error-classification branch from 87ffe07 to 5c513a9 Compare July 30, 2026 02:44
@colosair
colosair marked this pull request as ready for review July 30, 2026 02:45
@colosair
colosair merged commit b45aa93 into dev Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant