브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
155
kis_trader/utils/request_handler.py
Normal file
155
kis_trader/utils/request_handler.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 내부 유틸
|
||||
# ------------------------------------------------------------------
|
||||
def _throttle(self) -> None:
|
||||
"""호출 간 최소 간격 보장 (클라이언트 측 429 방지)."""
|
||||
with self._throttle_lock:
|
||||
elapsed = time.time() - self._last_call_ts
|
||||
if elapsed < self.min_interval_sec:
|
||||
time.sleep(self.min_interval_sec - elapsed)
|
||||
self._last_call_ts = time.time()
|
||||
|
||||
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:
|
||||
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):
|
||||
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)
|
||||
Reference in New Issue
Block a user