"""LS OpenAPI 접근토큰 — ``POST /oauth2/token`` (스펙 ``token``). 운영 준수: - 응답 ``expires_in``/``expire_in``(초) 로 만료 시각을 잡고 **만료 전 재사용** - 만료·IGW00121/123(무효/기간만료) 시에만 재발급 - 프로세스 공유 캐시 + 최소 재발급 간격(한도·폭주 방지) - ``/oauth2/revoke`` 는 정상 폐기용 — 매 루프 강제 발급에 쓰지 않음 - **국내↔해외 세션 전환·WS 재연결에서 force 재발급 금지** (LS 토큰은 조건검색 REST·국내/해외 WS 공용 — 재발급 시 상대 경로 토큰 무효화. 키움 과거 중복발급 사고와 동일 유형. force 는 auth 오류 1회 복구만.) 스펙 예시: 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)