232 lines
7.7 KiB
Python
232 lines
7.7 KiB
Python
"""LS OpenAPI 접근토큰 — ``POST /oauth2/token`` (스펙 ``token``).
|
|
|
|
운영 준수:
|
|
- 응답 ``expires_in``/``expire_in``(초) 로 만료 시각을 잡고 **만료 전 재사용**
|
|
- 만료·IGW00121/123(무효/기간만료) 시에만 재발급
|
|
- 프로세스 공유 캐시 + 최소 재발급 간격(한도·폭주 방지)
|
|
- ``/oauth2/revoke`` 는 정상 폐기용 — 매 루프 강제 발급에 쓰지 않음
|
|
|
|
스펙 예시: expires_in=86400 (24h). transactionPerSec='-'.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
from kis_trader.utils.env import get_env_float, get_env_int
|
|
|
|
logger = logging.getLogger("kis_trader.ls_token")
|
|
|
|
LS_REST_BASE = "https://openapi.ls-sec.co.kr:8080"
|
|
LS_TOKEN_URL = f"{LS_REST_BASE}/oauth2/token"
|
|
|
|
# 스펙 응답 예시 기본 유효기간(초)
|
|
_DEFAULT_EXPIRES_IN = 86400
|
|
|
|
# LS GW 인증 오류 코드 (재발급 트리거)
|
|
LS_AUTH_RSP_CODES = frozenset({"IGW00121", "IGW00123"})
|
|
|
|
|
|
def is_ls_auth_error(
|
|
*,
|
|
rsp_cd: Any = None,
|
|
rsp_msg: Any = None,
|
|
http_status: Any = None,
|
|
text: Any = None,
|
|
) -> bool:
|
|
"""만료·무효 토큰 응답인지."""
|
|
cd = str(rsp_cd or "").strip().upper()
|
|
if cd in LS_AUTH_RSP_CODES:
|
|
return True
|
|
blob = f"{rsp_msg or ''} {text or ''}".lower()
|
|
if any(
|
|
x in blob
|
|
for x in (
|
|
"기간이 만료된 token",
|
|
"유효하지 않은 token",
|
|
"invalid token",
|
|
"expired token",
|
|
)
|
|
):
|
|
return True
|
|
try:
|
|
st = int(http_status or 0)
|
|
except (TypeError, ValueError):
|
|
st = 0
|
|
if st in (401, 403) and "token" in blob:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _parse_expires_in(body: Dict[str, Any]) -> int:
|
|
"""스펙 필드 ``expire_in`` + 실제 응답 ``expires_in`` 모두 수용."""
|
|
raw = body.get("expires_in", body.get("expire_in", None))
|
|
try:
|
|
n = int(float(raw))
|
|
except (TypeError, ValueError):
|
|
n = 0
|
|
if n <= 0:
|
|
n = int(get_env_int("LS_TOKEN_EXPIRES_IN_DEFAULT", _DEFAULT_EXPIRES_IN) or _DEFAULT_EXPIRES_IN)
|
|
return max(60, n)
|
|
|
|
|
|
class LSTokenCache:
|
|
"""appkey 단위 공유 접근토큰 캐시."""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
# key = f"{app_key}|{app_secret[:8]}" → dict
|
|
self._by_key: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def _cache_key(self, app_key: str, app_secret: str) -> str:
|
|
return f"{(app_key or '').strip()}|{(app_secret or '')[:8]}"
|
|
|
|
def _margin_sec(self) -> float:
|
|
# 만료 N초 전부터 선제 갱신 (기본 600초)
|
|
return float(get_env_int("LS_TOKEN_REFRESH_MARGIN_SEC", 600) or 600)
|
|
|
|
def _min_reissue_sec(self) -> float:
|
|
# 연속 재발급 최소 간격 — 폭주 방지 (기본 60초)
|
|
return float(get_env_float("LS_TOKEN_MIN_REISSUE_SEC", 60.0) or 60.0)
|
|
|
|
def peek(self, app_key: str, app_secret: str) -> Optional[str]:
|
|
with self._lock:
|
|
ent = self._by_key.get(self._cache_key(app_key, app_secret))
|
|
if not ent:
|
|
return None
|
|
tok = str(ent.get("token") or "")
|
|
exp = float(ent.get("expire_at") or 0)
|
|
if tok and time.time() < exp - self._margin_sec():
|
|
return tok
|
|
return None
|
|
|
|
def get(
|
|
self,
|
|
app_key: str,
|
|
app_secret: str,
|
|
*,
|
|
force: bool = False,
|
|
reason: str = "",
|
|
timeout: float = 15.0,
|
|
) -> str:
|
|
"""유효 토큰 반환. force=True 여도 최소 재발급 간격 준수(캐시 유효하면 재사용)."""
|
|
app_key = (app_key or "").strip()
|
|
app_secret = (app_secret or "").strip()
|
|
if not (app_key and app_secret):
|
|
raise RuntimeError("LS appkey/appsecret 필요")
|
|
|
|
ck = self._cache_key(app_key, app_secret)
|
|
with self._lock:
|
|
ent = self._by_key.get(ck) or {}
|
|
tok = str(ent.get("token") or "")
|
|
exp = float(ent.get("expire_at") or 0)
|
|
last_iss = float(ent.get("issued_at") or 0)
|
|
now = time.time()
|
|
margin = self._margin_sec()
|
|
still_ok = bool(tok) and now < (exp - margin)
|
|
|
|
if still_ok and not force:
|
|
return tok
|
|
|
|
# force 여도 방금 발급분이면 재사용 (한도·폭주 방지)
|
|
min_gap = self._min_reissue_sec()
|
|
if still_ok and force and (now - last_iss) < min_gap:
|
|
logger.info(
|
|
"LS 토큰 재발급 스킵(최소간격 %.0fs, reason=%s) → 캐시 재사용",
|
|
min_gap, reason or "force",
|
|
)
|
|
return tok
|
|
|
|
# 만료 직전·만료·강제 — 발급
|
|
if still_ok and force:
|
|
logger.info("LS 토큰 재발급 요청 reason=%s (캐시 유효하나 force)", reason or "force")
|
|
elif tok and not still_ok:
|
|
logger.info(
|
|
"LS 토큰 만료/임박 → /oauth2/token 발급 (남은 %.0fs, reason=%s)",
|
|
exp - now, reason or "expire",
|
|
)
|
|
else:
|
|
logger.info("LS 토큰 신규 발급 (/oauth2/token) reason=%s", reason or "start")
|
|
|
|
body = self._issue(app_key, app_secret, timeout=timeout)
|
|
token = str(body.get("access_token") or body.get("accesstoken") or "")
|
|
if not token:
|
|
raise RuntimeError(f"LS token empty: {body}")
|
|
expires_in = _parse_expires_in(body)
|
|
expire_at = now + float(expires_in)
|
|
self._by_key[ck] = {
|
|
"token": token,
|
|
"expire_at": expire_at,
|
|
"expires_in": expires_in,
|
|
"issued_at": now,
|
|
}
|
|
logger.info(
|
|
"✅ LS 접근토큰 발급 | expires_in=%ds | 만료까지 %.1fh | reason=%s",
|
|
expires_in, expires_in / 3600.0, reason or "ok",
|
|
)
|
|
return token
|
|
|
|
@staticmethod
|
|
def _issue(app_key: str, app_secret: str, *, timeout: float) -> Dict[str, Any]:
|
|
resp = requests.post(
|
|
LS_TOKEN_URL,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"appkey": app_key,
|
|
"appsecretkey": app_secret,
|
|
"scope": "oob",
|
|
},
|
|
timeout=timeout,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise RuntimeError(
|
|
f"LS token HTTP {resp.status_code}: {resp.text[:300]}"
|
|
)
|
|
try:
|
|
body = resp.json()
|
|
except Exception as e:
|
|
raise RuntimeError(f"LS token JSON 실패: {e}") from e
|
|
if not isinstance(body, dict):
|
|
raise RuntimeError(f"LS token body 형식 오류: {body!r}")
|
|
return body
|
|
|
|
|
|
_CACHE = LSTokenCache()
|
|
|
|
|
|
def fetch_ls_access_token(
|
|
app_key: str,
|
|
app_secret: str,
|
|
timeout: float = 15.0,
|
|
*,
|
|
force: bool = False,
|
|
reason: str = "",
|
|
) -> str:
|
|
"""호환 래퍼 — 문자열 토큰만 반환 (expires_in 은 캐시에 보관)."""
|
|
return _CACHE.get(
|
|
app_key, app_secret, force=force, reason=reason, timeout=timeout,
|
|
)
|
|
|
|
|
|
def fetch_ls_access_token_info(
|
|
app_key: str,
|
|
app_secret: str,
|
|
timeout: float = 15.0,
|
|
*,
|
|
force: bool = False,
|
|
reason: str = "",
|
|
) -> Tuple[str, float, int]:
|
|
"""(token, expire_at_epoch, expires_in_sec)."""
|
|
tok = _CACHE.get(
|
|
app_key, app_secret, force=force, reason=reason, timeout=timeout,
|
|
)
|
|
with _CACHE._lock:
|
|
ent = _CACHE._by_key.get(_CACHE._cache_key(app_key, app_secret)) or {}
|
|
return tok, float(ent.get("expire_at") or 0), int(ent.get("expires_in") or 0)
|