#!/usr/bin/env python3 """ kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시 (국내·해외 WS 공유) ================================================================================== REST access_token(.kis_token_cache_*.json) 과 동일 패턴: - 프로세스·재시작·국내/해외 WS 가 **하나의 approval_key** 를 공유 - KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지 - 파일 잠금으로 동시 발급(국내+해외·재시작) 경합 방지 문제(기존): - kis_ws / kis_ws_overseas 가 각각 /oauth2/Approval 호출 → 나중 키가 앞 키 무효화 - 메모리만 사용 → 재시작마다 새 발급 → invalid approval 루프 응급 예외(invalid approval): - 서버가 캐시 키를 이미 거부하는데 6h 가드만 지키면 동일 키 루프 - bypass_min_reissue / emergency_reissue 로 REST 재발급 허용 """ from __future__ import annotations import json import logging import os import threading import time from datetime import datetime from pathlib import Path from typing import Optional import requests logger = logging.getLogger(__name__) ROOT = Path(__file__).parent CACHE_MOCK = ROOT / ".kis_approval_cache_mock.json" CACHE_REAL = ROOT / ".kis_approval_cache_real.json" LOCK_FILE = ROOT / ".kis_approval_manager.lock" LOCK_TIMEOUT_S = 60 DEFAULT_MIN_REISSUE_SEC = 21600.0 DEFAULT_MAX_VALID_SEC = 82800.0 # 응급 재발급 하드캡 파일 (실전/모의 공통 간격 — 앱키당 1키 정책) EMERGENCY_META_REAL = ROOT / ".kis_approval_emergency_real.json" EMERGENCY_META_MOCK = ROOT / ".kis_approval_emergency_mock.json" _am_instances: dict = {} _am_instances_lock = threading.Lock() def _min_reissue_sec() -> float: try: raw = os.environ.get("KIS_WS_APPROVAL_MIN_REISSUE_SEC", "").strip() return max(0.0, float(raw)) if raw else DEFAULT_MIN_REISSUE_SEC except (ValueError, TypeError): return DEFAULT_MIN_REISSUE_SEC def _max_valid_sec() -> float: try: raw = os.environ.get("KIS_WS_APPROVAL_MAX_VALID_SEC", "").strip() return max(3600.0, float(raw)) if raw else DEFAULT_MAX_VALID_SEC except (ValueError, TypeError): return DEFAULT_MAX_VALID_SEC def _emergency_cap_sec() -> float: """응급 재발급 최소 간격(초). 기본=6h — 영구정지 위험 완화.""" try: raw = os.environ.get("KIS_WS_EMERGENCY_REISSUE_MIN_SEC", "").strip() if raw: return max(0.0, float(raw)) # 미설정 시 정상 6h 가드와 동일 return _min_reissue_sec() except (ValueError, TypeError): return DEFAULT_MIN_REISSUE_SEC def _acquire_lock() -> bool: deadline = time.time() + LOCK_TIMEOUT_S while time.time() < deadline: try: fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.write(fd, str(os.getpid()).encode()) os.close(fd) return True except FileExistsError: try: if time.time() - LOCK_FILE.stat().st_mtime > 300: LOCK_FILE.unlink(missing_ok=True) continue except Exception: pass time.sleep(1) except Exception as exc: logger.warning("approval 잠금 획득 실패: %s", exc) return False logger.warning("approval 잠금 타임아웃 → 파일 캐시 재사용 시도") return False def _release_lock() -> None: try: LOCK_FILE.unlink(missing_ok=True) except Exception: pass def _issue_rest(app_key: str, app_secret: str, base_url: str) -> Optional[str]: url = f"{base_url.rstrip('/')}/oauth2/Approval" body = { "grant_type": "client_credentials", "appkey": app_key, "secretkey": app_secret, } try: resp = requests.post(url, json=body, timeout=10) data = resp.json() key = str(data.get("approval_key") or "").strip() if key: return key logger.error("❌ approval_key REST 발급 실패: %s", data) except Exception as exc: logger.error("❌ approval_key REST 요청 예외: %s", exc) return None class KISApprovalManager: """KIS WebSocket approval_key 싱글톤 — 실전/모의 각 1개.""" @classmethod def instance(cls, is_mock: bool) -> "KISApprovalManager": with _am_instances_lock: if is_mock not in _am_instances: _am_instances[is_mock] = cls(is_mock) return _am_instances[is_mock] def __init__(self, is_mock: bool) -> None: self._is_mock = bool(is_mock) self._mode_str = "모의" if is_mock else "실전" self._cache_path = CACHE_MOCK if is_mock else CACHE_REAL self._lock = threading.Lock() self._approval_key: Optional[str] = None self._issued_ts: float = 0.0 self._app_key_prefix: str = "" self._load_from_file() def _load_from_file(self) -> None: if not self._cache_path.exists(): return try: data = json.loads(self._cache_path.read_text(encoding="utf-8")) key = str(data.get("approval_key") or "").strip() ts = float(data.get("issued_at_epoch") or 0.0) prefix = str(data.get("app_key_prefix") or "") if not key or ts <= 0: return age = time.time() - ts if age >= _max_valid_sec(): return self._approval_key = key self._issued_ts = ts self._app_key_prefix = prefix except Exception as exc: logger.debug("approval 파일 로드 실패 [%s]: %s", self._mode_str, exc) def _save_to_file(self) -> None: if not self._approval_key or self._issued_ts <= 0: return try: self._cache_path.write_text( json.dumps( { "approval_key": self._approval_key, "issued_at": datetime.fromtimestamp(self._issued_ts).strftime( "%Y-%m-%d %H:%M:%S" ), "issued_at_epoch": self._issued_ts, "app_key_prefix": self._app_key_prefix, "mock": self._is_mock, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) except Exception as exc: logger.warning("approval 파일 저장 실패 (%s): %s", self._cache_path.name, exc) def age_sec(self) -> float: if not self._issued_ts: return 999999.0 return max(0.0, time.time() - self._issued_ts) def reload_from_file(self) -> Optional[str]: """REST 없이 파일 캐시만 다시 읽기 (다른 프로세스 갱신 반영).""" with self._lock: self._load_from_file() return self._approval_key def clear_cached_key(self, reason: str = "") -> None: """메모리·파일 캐시 삭제 (죽은 키 루프 탈출용).""" with self._lock: had = bool(self._approval_key) self._approval_key = None self._issued_ts = 0.0 self._app_key_prefix = "" try: self._cache_path.unlink(missing_ok=True) except Exception as exc: logger.warning("approval 캐시 파일 삭제 실패: %s", exc) if had or reason: logger.info( "🧹 approval_key 캐시 삭제 [%s]%s", self._mode_str, f" ({reason})" if reason else "", ) def _emergency_meta_path(self) -> Path: return EMERGENCY_META_MOCK if self._is_mock else EMERGENCY_META_REAL def _last_emergency_ts(self) -> float: path = self._emergency_meta_path() if not path.exists(): return 0.0 try: data = json.loads(path.read_text(encoding="utf-8")) return float(data.get("last_emergency_ts") or 0.0) except Exception: return 0.0 def _save_emergency_ts(self, ts: float, reason: str = "") -> None: path = self._emergency_meta_path() try: path.write_text( json.dumps( { "last_emergency_ts": ts, "last_emergency_at": datetime.fromtimestamp(ts).strftime( "%Y-%m-%d %H:%M:%S" ), "reason": (reason or "")[:200], "mock": self._is_mock, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) except Exception as exc: logger.warning("응급 재발급 메타 저장 실패: %s", exc) def emergency_reissue( self, app_key: str, app_secret: str, base_url: str, *, reason: str = "invalid approval", ) -> Optional[str]: """ 서버가 캐시 키를 거부할 때 6h 가드를 우회해 REST 재발급. ★ 하드캡: KIS_WS_EMERGENCY_REISSUE_MIN_SEC(기본 6h) 내 1회만 허용. (정상 재연결 경로에서는 호출하지 말 것 — invalid 연속·수동 응급만) """ cap = _emergency_cap_sec() last = self._last_emergency_ts() now = time.time() if cap > 0 and last > 0 and (now - last) < cap: remain = cap - (now - last) logger.error( "⛔ approval 응급 재발급 하드캡 — %.0f분 전 이미 1회 사용 " "(남은 대기 %.0f분, 간격 %.0fh). 죽은 키 루프여도 REST 재발급 안 함. " "reason=%s", (now - last) / 60.0, remain / 60.0, cap / 3600.0, reason, ) # 캐시가 있으면 그대로 반환(루프는 WS 쪽 쿨다운), 없으면 None with self._lock: self._load_from_file() return self._approval_key logger.warning( "🚨 approval_key 응급 REST 재발급 시작 [%s] reason=%s " "(하드캡: %.0fh에 1회)", self._mode_str, reason, cap / 3600.0 if cap > 0 else 0.0, ) self.clear_cached_key(reason=reason) key = self.get_approval_key( app_key, app_secret, base_url, force_refresh=True, bypass_min_reissue=True, ) if key: self._save_emergency_ts(time.time(), reason=reason) return key @property def issued_ts(self) -> float: return self._issued_ts def get_approval_key( self, app_key: str, app_secret: str, base_url: str, *, force_refresh: bool = False, bypass_min_reissue: bool = False, ) -> Optional[str]: """ 유효한 approval_key 반환. - 24h 이내: 파일/메모리 캐시 재사용 (재연결 포함) - 6h 미경과: force_refresh=True 여도 REST 재발급 금지 (단 bypass_min_reissue=True 이면 응급 재발급 허용) - 6h 경과 + 24h 미만 + force_refresh: REST 재발급 허용 - 24h 경과: REST 재발급 """ app_key = (app_key or "").strip() app_secret = (app_secret or "").strip() if not app_key or not app_secret: return None min_reissue = _min_reissue_sec() max_valid = _max_valid_sec() with self._lock: self._load_from_file() age = self.age_sec() prefix_ok = ( not self._app_key_prefix or app_key.startswith(self._app_key_prefix) or self._app_key_prefix.startswith(app_key[:8]) ) if self._approval_key and age < max_valid and prefix_ok: if force_refresh and (bypass_min_reissue or age >= min_reissue): pass elif force_refresh and age < min_reissue and not bypass_min_reissue: logger.info( "🔑 approval_key REST 재발급 생략 (KIS 6h — %.0f분/%.0fh, %s 재사용)", age / 60, min_reissue / 3600, self._cache_path.name, ) return self._approval_key else: return self._approval_key if not _acquire_lock(): with self._lock: self._load_from_file() return self._approval_key try: with self._lock: self._load_from_file() age = self.age_sec() if self._approval_key and age < max_valid: if not force_refresh: return self._approval_key if age < min_reissue and not bypass_min_reissue: return self._approval_key key = _issue_rest(app_key, app_secret, base_url) if not key: with self._lock: return self._approval_key now = time.time() with self._lock: self._approval_key = key self._issued_ts = now self._app_key_prefix = app_key[:8] self._save_to_file() logger.info( "✅ WebSocket approval_key 발급·저장 (%s, 앞8자: %s…, " "다음 REST 가능: %.0fh 후 → %s%s)", self._mode_str, key[:8], min_reissue / 3600, self._cache_path.name, ", 응급우회" if bypass_min_reissue else "", ) return key finally: _release_lock() def get_approval_status(is_mock: bool) -> dict: mgr = KISApprovalManager.instance(is_mock) with mgr._lock: mgr._load_from_file() age_h = round(mgr.age_sec() / 3600.0, 2) if mgr._approval_key else -1.0 valid = bool(mgr._approval_key) and mgr.age_sec() < _max_valid_sec() return { "valid": valid, "key_prefix": (mgr._approval_key[:8] + "…") if mgr._approval_key else "", "age_h": age_h, "file": str(mgr._cache_path.name), }