엔진 readiness 실패 캐시 TTL 분리와 프로브 타임아웃 보정

공개 런타임에서 reasoning_effort=high 콜드 프로브 1회가 20초를 넘기면
실패가 성공과 같은 TTL(운영 1800초)로 캐시되어 공개 API가 최장 30분간
engine=false로 오염되고, 워치독은 기본 /ready의 캐시된 200만 보고 API만
재시작하는 무한 루프에 빠졌다(2026-08-18 06:30 KST 실측).

- 실패 항목 전용 ENGINE_READY_FAILURE_TTL_SECONDS(기본 30초)를 분리해
  복구가 프로브 한 번 안에 감지되게 하고, 진짜 장애 중 프로브 폭풍은
  실패 TTL이 계속 막는다.
- ENGINE_READY_TIMEOUT_SECONDS 기본값을 20→45초로 올려 고효율 콜드
  스폰의 정상 지연을 장애로 오판하지 않는다.
- 회귀 4건 신설(실패 재프로브·성공 캐시 유지·실패 폭풍 억제·기본값 계약),
  게이트웨이 62/62·API 전체 986 passed·ruff clean.
This commit is contained in:
Yun Chan 2026-08-18 10:45:35 +09:00
parent 0c56f1857a
commit 4771b97c3a
2 changed files with 139 additions and 3 deletions

View file

@ -0,0 +1,122 @@
"""/ready 캐시의 실패 TTL 계약.
2026-08-18 공개 런타임 장애: reasoning_effort=high 콜드 프로브 1회가 20 타임아웃을
넘기자 실패 결과가 성공과 같은 TTL(운영 1800) 캐시돼, 공개 API가 30분간
engine=false로 오염됐다. 워치독은 기본 /ready(캐시 200) 보고 엔진을 healthy로
오판해 API만 재시작하는 무한 루프에 빠졌다. 실패는 짧은 TTL 재프로브해야 한다.
"""
import asyncio
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from engine_gateway import gateway
class _FakeProbe:
"""EngineSession 대역: 호출 순서별로 미리 정한 결과를 돌려준다."""
def __init__(self, outcomes, calls):
self._outcomes = outcomes
self._calls = calls
async def start(self):
return None
async def turn(self, content, timeout=None):
index = min(len(self._calls), len(self._outcomes) - 1)
outcome = self._outcomes[index]
self._calls.append(outcome)
if isinstance(outcome, Exception):
raise outcome
return {"text": outcome}
async def failure_detail(self, reason):
return reason
async def close(self):
return None
def _probe_factory(outcomes, calls):
def factory(*args, **kwargs):
return _FakeProbe(outcomes, calls)
return factory
class ReadyFailureCacheTest(unittest.TestCase):
def setUp(self):
gateway._READY_CACHE.clear()
self.addCleanup(gateway._READY_CACHE.clear)
def test_failed_probe_is_retried_after_failure_ttl(self):
calls = []
with (
patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", ""),
patch.object(gateway, "READY_TTL_SECONDS", 1800.0),
patch.object(gateway, "READY_FAILURE_TTL_SECONDS", 0.0),
patch.object(
gateway,
"EngineSession",
_probe_factory([asyncio.TimeoutError(), "OK"], calls),
),
):
client = TestClient(gateway.app)
first = client.get("/ready")
second = client.get("/ready")
self.assertEqual(first.status_code, 503)
self.assertEqual(second.status_code, 200)
self.assertEqual(len(calls), 2)
self.assertFalse(second.json()["cached"])
def test_successful_probe_stays_cached_for_full_ttl(self):
calls = []
with (
patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", ""),
patch.object(gateway, "READY_TTL_SECONDS", 1800.0),
patch.object(gateway, "READY_FAILURE_TTL_SECONDS", 0.0),
patch.object(
gateway,
"EngineSession",
_probe_factory(["OK", asyncio.TimeoutError()], calls),
),
):
client = TestClient(gateway.app)
first = client.get("/ready")
second = client.get("/ready")
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(len(calls), 1)
self.assertTrue(second.json()["cached"])
def test_failed_probe_is_not_hammered_within_failure_ttl(self):
calls = []
with (
patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", ""),
patch.object(gateway, "READY_TTL_SECONDS", 1800.0),
patch.object(gateway, "READY_FAILURE_TTL_SECONDS", 60.0),
patch.object(
gateway,
"EngineSession",
_probe_factory([asyncio.TimeoutError(), "OK"], calls),
),
):
client = TestClient(gateway.app)
first = client.get("/ready")
second = client.get("/ready")
self.assertEqual(first.status_code, 503)
self.assertEqual(second.status_code, 503)
self.assertEqual(len(calls), 1)
self.assertTrue(second.json()["cached"])
def test_defaults_give_cold_high_effort_spawn_headroom(self):
# reasoning_effort=high 콜드 스폰은 20초를 자주 넘긴다(2026-08-18 실측).
self.assertGreaterEqual(gateway.READY_TIMEOUT_SECONDS, 45.0)
# 실패 캐시는 1분 넘게 유지하면 안 된다 — 복구 지연이 곧 공개 장애 시간이 된다.
self.assertLessEqual(gateway.READY_FAILURE_TTL_SECONDS, 60.0)