""" kis_trader/utils/request_handler.py — SafeRequest 공통 HTTP 래퍼 ================================================================ .cursorrules 규정: "모든 API 요청은 utils/request_handler.py 의 SafeRequest 클래스를 상속받아 구현하세요. HTTP 429(Too Many Requests) 에러 발생 시 재시도(Retry) 로직을 반드시 포함하세요." - 한투 EGW00201 (초당 거래 초과)도 동일 취급. - 최소 호출 간격 (`min_interval_sec`) 으로 클라이언트 측 스로틀. - 재시도 시 지수 백오프 (1.0s → 2.0s → 4.0s ... cap 10s) - 타임아웃 발생 시 1회 재시도 허용. """ from __future__ import annotations import random import threading import time from typing import Any, Optional import requests from .logger import get_logger logger = get_logger("kis_trader.safe_request") class SafeRequest: """ HTTP 요청 공통 기반 클래스. 한투·키움 REST 클라이언트가 상속해서 사용. """ # 429 상태 코드 or 한투 초당거래 초과 msg_cd RETRYABLE_STATUSES = {429, 500, 502, 503, 504} KIS_RATE_LIMIT_MSG_CD = {"EGW00201", "EGW00202"} def __init__( self, min_interval_sec: float = 0.22, max_retries: int = 5, backoff_base: float = 1.0, backoff_cap: float = 10.0, timeout_sec: float = 10.0, ): # 기본값은 안전 마진(한투 REST 공식 초당 5건 내외 감안) self.min_interval_sec = float(min_interval_sec) self.max_retries = int(max_retries) self.backoff_base = float(backoff_base) self.backoff_cap = float(backoff_cap) self.timeout_sec = float(timeout_sec) self._last_call_ts: float = 0.0 self._throttle_lock = threading.Lock() # ── 계측: 초당 호출 수 / 큐 대기시간 (피크 실측용) ─────────────── self._calls_in_window: int = 0 self._window_start: float = time.time() self._peak_calls_per_sec: int = 0 self._total_calls: int = 0 self._total_wait_sec: float = 0.0 self._peak_wait_sec: float = 0.0 self._rate_limit_hits: int = 0 def _current_min_interval(self) -> float: """호출 직전 min_interval 재해석 훅 (서브클래스가 env 핫리로드용으로 오버라이드). 기본은 고정 ``self.min_interval_sec`` 반환. 오버라이드 시 재시작 없이 반영. """ return self.min_interval_sec def _record_call(self, waited: float) -> None: """계측 누적 — _throttle 내부에서 호출 (lock 보유 상태).""" now = time.time() self._total_calls += 1 self._total_wait_sec += waited if waited > self._peak_wait_sec: self._peak_wait_sec = waited # 1초 윈도우 카운트 if now - self._window_start >= 1.0: self._window_start = now self._calls_in_window = 1 else: self._calls_in_window += 1 if self._calls_in_window > self._peak_calls_per_sec: self._peak_calls_per_sec = self._calls_in_window def get_throttle_stats(self) -> dict: """계측 스냅샷 (모니터링·검증용).""" avg_wait = ( self._total_wait_sec / self._total_calls if self._total_calls else 0.0 ) return { "total_calls": self._total_calls, "peak_calls_per_sec": self._peak_calls_per_sec, "avg_wait_sec": round(avg_wait, 4), "peak_wait_sec": round(self._peak_wait_sec, 4), "rate_limit_hits": self._rate_limit_hits, "min_interval_sec": self._current_min_interval(), } # ------------------------------------------------------------------ # 내부 유틸 # ------------------------------------------------------------------ def _throttle(self) -> None: """호출 간 최소 간격 보장 (클라이언트 측 429 방지).""" with self._throttle_lock: interval = self._current_min_interval() elapsed = time.time() - self._last_call_ts waited = 0.0 if elapsed < interval: waited = interval - elapsed time.sleep(waited) self._last_call_ts = time.time() self._record_call(waited) def _sleep_backoff(self, attempt: int) -> None: """지수 백오프 + 지터. attempt=1,2,3...""" wait = min(self.backoff_base * (2 ** (attempt - 1)), self.backoff_cap) wait += random.uniform(0, 0.3) time.sleep(wait) @staticmethod def _is_kis_rate_limited(json_body: dict) -> bool: if not isinstance(json_body, dict): return False return json_body.get("msg_cd") in SafeRequest.KIS_RATE_LIMIT_MSG_CD # ------------------------------------------------------------------ # 외부 인터페이스 # ------------------------------------------------------------------ def request( self, method: str, url: str, *, headers: Optional[dict] = None, params: Optional[dict] = None, json_body: Optional[dict] = None, data: Optional[Any] = None, timeout: Optional[float] = None, ) -> requests.Response: """ 재시도/스로틀이 포함된 HTTP 호출. 한투 EGW00201 같은 `status=200 but rate-limited` 도 재시도. 실패 시 마지막 Response(또는 빈 Response) 반환. """ timeout = timeout or self.timeout_sec last_resp: Optional[requests.Response] = None for attempt in range(1, self.max_retries + 1): self._throttle() try: resp = requests.request( method.upper(), url, headers=headers, params=params, json=json_body, data=data, timeout=timeout, ) last_resp = resp # [1] 재시도 대상 HTTP 상태 if resp.status_code in self.RETRYABLE_STATUSES: if resp.status_code == 429: self._rate_limit_hits += 1 try: from kis_trader.utils.ops_alert import note_counter, ops_alert from kis_trader.utils.env import get_env_int streak = note_counter("rate_limit") need = max(1, get_env_int("OPS_ALERT_RATE_LIMIT_STREAK", 5)) if streak >= need: ops_alert( "rate_limit", f"HTTP 429 유량 연속 {streak}회", detail=f"{method.upper()} {url}", level="critical", ) note_counter("rate_limit", reset=True) except Exception: pass logger.warning( "HTTP %d on %s %s (%d/%d) → 백오프 후 재시도", resp.status_code, method.upper(), url, attempt, self.max_retries, ) self._sleep_backoff(attempt) continue # [2] 한투 EGW00201 (HTTP 200 but 초당거래 초과) if resp.status_code == 200: try: body = resp.json() except Exception: body = None if body and self._is_kis_rate_limited(body): self._rate_limit_hits += 1 try: from kis_trader.utils.ops_alert import note_counter, ops_alert from kis_trader.utils.env import get_env_int streak = note_counter("rate_limit") need = max(1, get_env_int("OPS_ALERT_RATE_LIMIT_STREAK", 5)) if streak >= need: ops_alert( "rate_limit", f"KIS 유량초과 연속 {streak}회", detail=f"{body.get('msg_cd')} {url}", level="critical", ) note_counter("rate_limit", reset=True) except Exception: pass logger.warning( "KIS rate-limit %s on %s (%d/%d) → 백오프 후 재시도", body.get("msg_cd"), url, attempt, self.max_retries, ) self._sleep_backoff(attempt) continue return resp except requests.exceptions.Timeout: logger.warning( "⏰ Timeout %s %s (%d/%d)", method.upper(), url, attempt, self.max_retries, ) time.sleep(1.0) except requests.exceptions.RequestException as e: logger.warning( "⚠️ RequestException %s %s (%d/%d): %s", method.upper(), url, attempt, self.max_retries, e, ) self._sleep_backoff(attempt) return last_resp if last_resp is not None else requests.Response() def get(self, url: str, **kw) -> requests.Response: return self.request("GET", url, **kw) def post(self, url: str, **kw) -> requests.Response: return self.request("POST", url, **kw)