# CI-2b — conftest limiter reset fail-closed 전환 (task-2796)

- 작업일: 2026-07-19
- 팀: dev2-team (오딘 / 토르)
- 워크트리: `/home/jay/projects/InsuRo/.worktrees/ci-2` · 브랜치 `task/ci-2-260719`
- 커밋: `e404e44` (a95972e 위 **추가 1개**) · PR 0 · merge 0 · push 0

---

## S (Situation)
task-2790(CI-2)에서 도입한 conftest rate limiter reset fixture는 테스트 간 429 누수는 막았으나, 실패를 조용히 삼키는 **fail-open** 구조였다.

## C (Complication)
fail-open 지점이 3곳이었다.
1. `except Exception: pass` — `Limiter.reset()` 실패를 무음 처리
2. `except ImportError: _rate_limiter = None` — limiter 미확보 시 fixture가 통째로 no-op
3. negative control fixture의 `if _rate_limiter is None: yield; return` — 검증 자체를 무음 skip

→ slowapi/limits 버전이 바뀌어 `reset()`이 깨지면 **아무도 모른 채 429 누수가 부활**하고, 스위트는 다시 조용히 flaky해진다.

## Q (Question)
reset이 깨졌을 때 스위트가 **시끄럽게 실패**하도록 만들되, negative control의 검증력은 약화시키지 않을 수 있는가?

## A (Answer)
3곳 모두 fail-closed로 전환했다. 변경은 `server/tests/conftest.py` 1파일(+30 / -12), 신규 파일 0.

---

## 변경 내용 (diff 요약)

`server/tests/conftest.py` 175~206행, 238~240행:

| 지점 | 전 (fail-open) | 후 (fail-closed) |
|---|---|---|
| import | `except ImportError: _rate_limiter = None` | `except Exception as exc:` → `_rate_limiter_import_error = exc` 로 **원본 예외 보존** |
| limiter 미확보 | `if _rate_limiter is None: return` | `raise RuntimeError(...) from _rate_limiter_import_error` |
| reset 실패 | `except Exception: pass` | `except Exception as exc: raise RuntimeError(...) from exc` |
| negative control | `if _rate_limiter is None: yield; return` | 삭제 (무음 통과 제거) |

에러 메시지에는 **원인 + 왜 치명적인지 + 조치 방향**을 모두 담았다.
- limiter 미확보: "`from main import limiter` 임포트 실패, 원본 예외는 `__cause__` 확인 … 조치: `server/main.py`의 `limiter = Limiter(...)` 정의, slowapi 설치 여부, import 경로(sys.path·순환 임포트) 확인"
- reset 실패: "slowapi `Limiter.reset()` 호출이 실패했다 … 전체 스위트에서만 재현되는 429 flaky 실패가 부활한다 … 조치: slowapi/limits 버전 확인 후 대체 API로 갱신"

⚠️ **negative control 동작은 원형 유지**: 7회 연속 호출 → `assert 429 in statuses` 및 프로브 후 즉시 reset하는 로직은 손대지 않았다 (diff에서 삭제된 것은 무음 skip 3줄뿐).

---

## fail-closed 증명 실험 (원문 출력)

**저장소 파일을 전혀 수정하지 않고** 외부 pytest 플러그인(`/tmp/failclosed_probe.py`)으로 `main.limiter.reset`을 예외 발생 함수로 교체해 재현했다. (팀장이 직접 재실행 — 실험 코드는 저장소 밖에 있으므로 커밋 오염 0)

```python
# /tmp/failclosed_probe.py
def pytest_configure(config):
    sys.path.insert(0, os.path.join(os.getcwd()))
    from main import limiter
    def _boom(*a, **kw):
        raise RuntimeError("SIMULATED: slowapi Limiter.reset() breakage")
    limiter.reset = _boom
```

실행 및 결과:
```
$ PYTHONPATH=/tmp python3 -m pytest tests/test_main.py -k "TestGenerateStubEndpoint" \
    -q -p no:randomly -p failclosed_probe

________ ERROR at setup of TestGenerateStubEndpoint.test_generate_stub _________
    def _reset_rate_limiter_storage() -> None:
        ...
        try:
>           _rate_limiter.reset()

tests/conftest.py:203:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
>       raise RuntimeError("SIMULATED: slowapi Limiter.reset() breakage")
E       RuntimeError: SIMULATED: slowapi Limiter.reset() breakage

/tmp/failclosed_probe.py:6: RuntimeError
=========================== short test summary info ============================
ERROR tests/test_main.py::TestGenerateStubEndpoint::test_generate_stub - Runt...
ERROR tests/test_main.py::TestGenerateStubEndpoint::test_generate_without_auth
52 deselected, 4 warnings, 2 errors in 0.21s
```

**대조군(정상 상태, 같은 명령에서 플러그인만 제거)**:
```
$ python3 -m pytest tests/test_main.py -k "TestCostCircuitBreaker or TestGenerateStubEndpoint" -q -p no:randomly
5 passed, 49 deselected, 7 warnings in 0.21s
```

→ 수정 전이었다면 동일 상황에서 `pass`로 삼켜져 **조용히 통과**했을 것이다. 이제는 세션이 error로 죽는다. **fail-closed 성립.**

토르(팀원)도 별도로 conftest 임시 편집 방식으로 동일 결론을 얻었고, 실험 후 원복 + `grep TEMP` 0건을 확인했다. 팀장 재실행은 저장소 무수정 방식이라 커밋 오염 가능성 자체가 없다.

---

## 테스트 결과 (goal_assertion)

```
$ cd /home/jay/projects/InsuRo/.worktrees/ci-2/server
$ python3 -m pytest tests -q -p no:randomly
16 failed, 658 passed, 38 warnings in 124.71s (0:02:04)   # 674 collected
```

**테스트 카운트 전후**: 674 collected → 674 collected (감소 0). skip/xfail 증가 0. conftest에는 `test_*` 함수가 없으므로 수집 대상 변화 요인 자체가 없다.

**단독 실행**:
- `TestCostCircuitBreaker` + `TestGenerateStubEndpoint` 단독 → **5 passed**
- `tests/test_main.py` 전체 순서 내 → 53 passed / 1 failed (hanwha, 아래 GAP 참조) — 두 클래스 전원 PASS

**negative control 여전히 동작**: session-scope autouse fixture이므로 매 실행마다 1회 강제 실행되며, 429가 한 번도 관측되지 않으면 `assert`가 터져 스위트 전체가 error로 죽는다. 위 실행들이 정상 통과했다는 것은 곧 **429가 실제로 관측되었다**는 뜻이다.

---

## GAP — 잔존 실패 16건 (지시서 기대치 4건과 차이)

지시서는 "keyword_pool 4건 외 0"을 기대했으나 워크트리에서는 **16건**이 관측됐다. 이 12건 차이가 우리 커밋 탓인지 팀장이 직접 갈랐다.

이전 커밋(a95972e)의 conftest로 되돌려 동일 테스트만 재실행:
```
$ git checkout HEAD~1 -- server/tests/conftest.py
$ python3 -m pytest tests/test_security_patch.py \
    tests/test_main.py::TestParsePremiumFile::test_detect_company_hanwha -q -p no:randomly
12 failed, 4 warnings in 0.38s
```
→ **12건 전원이 우리 커밋 이전부터 동일하게 실패**. 실패 테스트 목록도 1:1 일치. 즉 **본 커밋의 회귀는 0건**이다.

- `test_security_patch.py` 11건: `FileNotFoundError` — 저장소 루트 기준 상대경로로 소스 파일을 읽는 테스트라 **worktree 환경에서 경로가 어긋나 실패**하는 것으로 보인다(관측된 에러 타입 기준 추정. 확정하려면 해당 테스트의 경로 계산 로직 확인 필요 — CI-2b 범위 밖).
- `test_main.py::test_detect_company_hanwha` 1건: 별개 기존 실패.
- `test_keyword_pool_refresh.py` 4건: 지시서가 예고한 CI-4 축.

→ 지시서의 "4건" 기대치는 **메인 저장소 기준**이었고, 워크트리에서는 환경 기인 12건이 추가로 보이는 것으로 판단된다. 아누 재실행 시 동일 현상이 나올 수 있어 명시한다.

## MATCH

- [x] `except Exception: pass` 제거 → reset 실패 시 세션 명시적 실패
- [x] `_rate_limiter is None` 무음 통과 제거 → 명시적 실패
- [x] 실패 메시지에 원인 + 조치 방향 포함 (깨진 API명 명시)
- [x] negative control fixture 유지 · 동작 약화 0
- [x] fail-closed 인위적 실패 실험 원문 첨부
- [x] 테스트 개수 감소 0 · skip/xfail 증가 0
- [x] expected_files(`server/tests/conftest.py`) 밖 수정 0 · 신규 파일 0 · `server/main.py` 무수정
- [x] 추가 커밋 정확히 1개 · PR 0 · merge 0 · push 0

---

## L1 스모크테스트 결과

- **서버 재시작**: 해당없음 (테스트 인프라 fixture 변경으로 런타임 서버 코드 무관 — `server/main.py` 무수정)
- **API 응답 확인**: 해당없음 (프로덕션 API 미변경). 대신 **negative control fixture가 in-process TestClient로 `/api/insuro/ai/generate`를 7회 실호출**하여 429를 실제 관측 — 매 pytest 실행마다 강제 수행되며 위 674-test 실행에서 통과 확인.
- **스크린샷**: 해당없음 (프론트엔드 변경 0)
- **L1 실행 판정**: **통과** — 실동작 검증축은 (a) fail-closed 인위 파손 실험에서 세션이 실제 error로 죽는 것을 원문으로 확인, (b) negative control의 실제 HTTP 429 관측. 둘 다 pytest PASS 문구가 아닌 **관측된 동작**이다.

## 발견 이슈 및 해결

1. **토르 보고에 diff 원문 누락** → 팀장이 `git show HEAD`로 직접 확인. 커밋 내용이 지시와 일치함을 검증 완료.
2. **린터 진단에 `_a`/`_kw` 미사용 심볼 경고** → 토르의 임시 실험 코드 흔적일 가능성을 의심하고 `grep`으로 확인 → **현재 파일에 흔적 0건**, `git status` clean. 실험 진행 중 스냅샷된 stale 진단으로 판정.
3. **실패 16건 vs 지시서 4건 불일치** → HEAD~1 되돌림 실행으로 12건이 pre-existing임을 확정. 위 GAP 섹션에 기록.

## 머지 판단

- **머지 필요**: **No** — 지시서 절대 제약(PR 0 · main merge 0). 브랜치 커밋만 남긴다.
- **브랜치**: `task/ci-2-260719` (head `e404e44`)
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/ci-2`
- **머지 의견**: 변경 범위가 테스트 fixture 1파일로 좁고 회귀 0건이나, 머지 여부는 회장/아누 판단 대기.

## ⛔ 완료 차단 — GOAL-GATE BLOCKED (.done 미생성)

`finish-task.sh` 실행 결과: **QC 및 이후 게이트는 전부 통과**했으나 마지막 GOAL-GATE에서 fail-closed 차단되어 **`.done`이 생성되지 않았다.** (수동 .done 생성 금지 원칙 준수 — 임의 우회 시도 0)

통과한 게이트:
```
[GIT-GATE] PASS — uncommitted 0 · 마지막 커밋 변경 파일 1건
[MERGE-BASE] PASS — base가 origin/main 최신과 일치 (cc7476bf)
[SCOPE-GUARD] PASS  /  [IMPACT-GATE] PASS  /  [CI-PREFLIGHT] PASS (tsc exit=0)
[FINALIZE-ONLY] merge_policy=none honored — merge block 스킵 (PR 0 · merge 0 준수)
[G4-GATE] PASS (soft, lv1)
QC: .qc-result / .qc-done 생성 완료 (TRUST 전 항목 passed)
```

차단 지점:
```
[GOAL-GATE] FAIL: python3 -m pytest server/tests -q -p no:randomly
[GOAL-GATE] BLOCKED: goal_assertions FAIL (fail-closed)
```

### 차단 원인 (직접 측정으로 확정)

goal_assertion은 `bash -c` 로 **finish-task.sh의 cwd(= `/home/jay/workspace`)에서 실행**되는데, 그 경로에는 `server/tests`가 존재하지 않는다. 직접 재현:
```
$ cd /home/jay/workspace && timeout 30s bash -c "python3 -m pytest server/tests -q -p no:randomly"
collected 0 items
============================ no tests ran in 0.02s =============================
real 0m0.672s   EXIT=4
```
→ pytest usage error(exit 4)로 **0.67초 만에 실패**. TIMEOUT(30s)이 아닌 FAIL이 뜬 것이 이 가설과 정확히 일치한다(스위트 실제 소요는 124초라 올바른 cwd였다면 TIMEOUT이 떴어야 함).

즉 **InsuRo 워크트리 대상 작업인데 goal_assertion이 workspace 루트 기준으로 auto-generated** 된 경로 불일치다.

다만 정직하게 덧붙이면, **cwd를 워크트리로 바로잡아도 이 assertion은 통과할 수 없다**:
- 스위트 소요 124초 > GOAL_CMD_TIMEOUT 기본 30초 → TIMEOUT(fail-closed)
- 그리고 실패 16건이 존재 → 설령 timeout을 늘려도 FAIL

### 이것이 본 커밋의 결함이 아닌 근거

- 실패 16건 중 12건은 **이전 커밋 a95972e에서도 동일 실패**(위 GAP 섹션의 되돌림 실행으로 확정), 4건은 지시서가 예고한 CI-4 축 keyword_pool.
- 12건의 실체: `tests/test_security_patch.py`가 **이미 삭제된 워크트리의 절대경로를 하드코딩**하고 있다.
  ```
  E FileNotFoundError: [Errno 2] No such file or directory:
    '/home/jay/projects/InsuRo/.worktrees/task-2264-dev2/server/main.py'
  ```
  해당 워크트리가 사라진 뒤로 어디서 실행하든 실패하는 기존 결함이다.
- 지시서 자체가 "0 failed"를 문자 그대로 요구하지 않았다(“keyword_pool 4건 외 0”). 즉 **auto-generated goal_assertion이 사람의 의도보다 엄격**하다.

### 요청 (아누/회장 판단 필요)

CI-2b 본연의 요구사항은 전부 충족했고 회귀는 0건이나, 아래는 **본 task의 expected_files(`server/tests/conftest.py` 단독) 밖**이라 손대지 않았다. 임의 수정은 STOP_REPORT 위반이므로 판단을 요청한다.

1. `tests/test_security_patch.py`의 stale 하드코딩 경로 11건 — 별도 task로 분리 수정 필요 (CI 축 오염 중)
2. goal_assertion 실행 cwd를 프로젝트 루트로 잡는 문제 + 30초 timeout — 크로스 저장소(InsuRo) task 공통 이슈로 보임
3. 위 정리 전까지 본 task의 `.done`은 생성 불가 — 커밋 `e404e44`는 브랜치에 안전하게 남아 있음

## 모델 사용 기록

- 토르(백엔드): **sonnet** — conftest fail-closed 구현 + 1차 검증. haiku 미사용.
- 오딘(팀장, opus): 설계 지시 · diff 검증 · goal_assertion 재실행 · fail-closed 독립 재증명 · GAP 판별.
- 프레이야/미미르/헤임달: 미소집 (프론트/UX/별도 QA 축 없음 — 팀장이 직접 교차검증 수행).
