Files
kis_bot/kis_trader/utils/request_handler.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

209 lines
8.0 KiB
Python

"""
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
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
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)