코드리뷰 수정

This commit is contained in:
2026-03-06 15:22:40 +09:00
parent da5cef1925
commit ab75dc66f8
5 changed files with 553 additions and 140 deletions

View File

@@ -19,6 +19,53 @@ TTL_MINUTE_FULLDAY = 60 # 당일 전체 분봉 캐시 1분
TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분
class CircuitBreaker:
"""KIS API Circuit Breaker — 연속 N회 실패 시 일정 시간 요청 차단.
States:
CLOSED : 정상 운영 (요청 허용)
OPEN : 차단 (recovery_timeout 경과 전까지 요청 거부)
HALF_OPEN: 복구 시도 (1개 요청 허용 → 성공 시 CLOSED, 실패 시 OPEN)
"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 600):
self.failure_threshold = failure_threshold # 연속 실패 허용 횟수
self.recovery_timeout = recovery_timeout # 차단 후 복구 대기 시간 (초)
self.failure_count = 0
self.last_failure_time: Optional[float] = None
self.state = "CLOSED"
def can_request(self) -> bool:
"""요청 가능 여부 반환."""
if self.state == "CLOSED":
return True
if self.state == "OPEN":
if self.last_failure_time and time.time() - self.last_failure_time >= self.recovery_timeout:
self.state = "HALF_OPEN"
logger.info("⚡ Circuit breaker HALF_OPEN: 복구 시도")
return True
return False
return True # HALF_OPEN
def record_success(self) -> None:
"""성공 기록 — 차단 해제."""
if self.state != "CLOSED":
logger.info("✅ Circuit breaker CLOSED: 복구 완료")
self.failure_count = 0
self.state = "CLOSED"
def record_failure(self) -> None:
"""실패 기록 — 임계치 초과 시 차단."""
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == "HALF_OPEN" or self.failure_count >= self.failure_threshold:
self.state = "OPEN"
logger.warning(
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
f"{self.recovery_timeout}초 후 재시도"
)
class APICallMonitor:
"""API 호출 모니터링 및 통계"""
@@ -125,6 +172,7 @@ class KoreaInvestmentAPI:
instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
instance._token_lock = asyncio.Lock()
instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
instance.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
logger.info(
"🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...",
instance.base_url, instance.app_key[:6]
@@ -157,6 +205,9 @@ class KoreaInvestmentAPI:
# 모니터링
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
# Circuit Breaker (5회 연속 실패 → 10분 차단)
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
# 시작 시 설정 로깅 (키 마스킹)
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
f"base_url={self.base_url}, "
@@ -287,8 +338,14 @@ class KoreaInvestmentAPI:
raise
async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
"""API 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)"""
# Circuit Breaker 상태 확인
if not self.circuit_breaker.can_request():
wait_sec = self.circuit_breaker.recovery_timeout
logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}")
raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)")
# 동시 요청 수 제한 (세마포어)
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
@@ -337,23 +394,24 @@ class KoreaInvestmentAPI:
if response.status_code == 200:
duration = time.time() - start_time
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
self.circuit_breaker.record_success()
# 오류율 경고
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
logger.warning(
f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%} "
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
)
return response.json(), response.headers
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
elif response.status_code in [429, 500, 502, 503, 504]:
retry_count += 1
last_exception = Exception(
f"API rate limit or server error: {response.status_code} - {response.text}"
)
if attempt < self.max_retries:
# 지수 백오프 적용
delay = self.retry_delay * (self.retry_backoff ** attempt)
@@ -364,6 +422,7 @@ class KoreaInvestmentAPI:
await asyncio.sleep(delay)
continue
else:
self.circuit_breaker.record_failure()
logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}")
raise last_exception
@@ -401,19 +460,22 @@ class KoreaInvestmentAPI:
else:
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
logger.error(f"❌ API 타임아웃 최대 재시도 초과")
raise
except Exception as e:
# 예상치 못한 오류
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
raise
# 여기까지 왔다면 모든 재시도 실패
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
if last_exception:
raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)")