코드리뷰 수정
This commit is contained in:
@@ -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 요청 실패 (알 수 없는 오류)")
|
||||
|
||||
Reference in New Issue
Block a user