브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
@@ -135,14 +135,59 @@ def _release_lock():
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 발급 시도 백오프 (분당 1회 제한 / 네트워크 오류 누적 폭주 방지)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# KIS 가 EGW00133 (1분당 1회) 로 거부했을 때, 다음 시도까지 강제로 기다리는
|
||||
# 시간(초). 이 백오프가 없으면 토큰 만료 시점에 호출자(전략·헬스체크·봇 등)
|
||||
# 가 매번 _issue_token 을 호출 → KIS 정책 위반 패턴으로 분류될 위험.
|
||||
RATE_LIMIT_BACKOFF_S = 65 # EGW00133 받으면 65초 동안 재시도 금지
|
||||
GENERIC_FAIL_BACKOFF_S = 10 # 그 외 발급 실패 (네트워크/타임아웃 등)
|
||||
|
||||
# is_mock(bool) → 다음 시도 가능 시각 (epoch seconds)
|
||||
_next_issue_at: dict = {False: 0.0, True: 0.0}
|
||||
_next_issue_lock = threading.Lock()
|
||||
|
||||
|
||||
def _can_issue_now(is_mock: bool) -> tuple:
|
||||
"""
|
||||
백오프 시간이 지났는지 확인.
|
||||
|
||||
Returns:
|
||||
(allowed, wait_s) — allowed=False 면 wait_s 초 더 기다려야 함.
|
||||
"""
|
||||
with _next_issue_lock:
|
||||
now = time.time()
|
||||
next_at = _next_issue_at.get(is_mock, 0.0)
|
||||
if now < next_at:
|
||||
return False, next_at - now
|
||||
return True, 0.0
|
||||
|
||||
|
||||
def _set_backoff(is_mock: bool, seconds: float) -> None:
|
||||
"""다음 발급 시도까지 ``seconds`` 초 대기."""
|
||||
with _next_issue_lock:
|
||||
_next_issue_at[is_mock] = time.time() + seconds
|
||||
|
||||
|
||||
def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
"""
|
||||
KIS /oauth2/tokenP 엔드포인트로 새 토큰 발급 후 캐시 저장.
|
||||
성공 시 True, 실패 시 False.
|
||||
|
||||
※ KIS 정책: 23시간 내 중복 발급 시 서비스 제한 가능.
|
||||
※ KIS 정책: 23시간 내 중복 발급 시 서비스 제한 가능 + 분당 1회 제한 (EGW00133).
|
||||
이 함수는 만료 1시간 전까지 기존 토큰을 재사용하므로 하루 1회만 호출됨.
|
||||
발급 실패 시 백오프(`_set_backoff`)로 호출자 폭주 방지.
|
||||
"""
|
||||
# 백오프 미경과 → 즉시 실패 (호출자에게 재시도 부담 떠넘기기 금지)
|
||||
allowed, wait_s = _can_issue_now(is_mock)
|
||||
if not allowed:
|
||||
logger.warning(
|
||||
"⏸ %s 토큰 발급 백오프 중 (%.1fs 남음) → 발급 시도 건너뜀",
|
||||
"모의" if is_mock else "실전", wait_s,
|
||||
)
|
||||
return False
|
||||
|
||||
base_url = (
|
||||
"https://openapivts.koreainvestment.com:29443"
|
||||
if is_mock else
|
||||
@@ -165,9 +210,15 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
if not token:
|
||||
err = data.get("message") or data.get("error_description") or str(data)
|
||||
logger.error(f"❌ {mode} 토큰 발급 실패: {err}")
|
||||
# EGW00133: 1분당 1회 제한
|
||||
# EGW00133: 1분당 1회 제한 → 65초 강제 백오프 (호출자 폭주 차단)
|
||||
if "EGW00133" in str(data):
|
||||
logger.warning("⚠️ KIS 분당 발급 제한. 1분 후 재시도하세요.")
|
||||
_set_backoff(is_mock, RATE_LIMIT_BACKOFF_S)
|
||||
logger.warning(
|
||||
"⚠️ KIS 분당 발급 제한 (EGW00133). %ds 동안 재시도 차단.",
|
||||
RATE_LIMIT_BACKOFF_S,
|
||||
)
|
||||
else:
|
||||
_set_backoff(is_mock, GENERIC_FAIL_BACKOFF_S)
|
||||
return False
|
||||
|
||||
cache_path.write_text(
|
||||
@@ -178,11 +229,14 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# 성공 시 백오프 해제 (다음 만료 때 즉시 시도 가능)
|
||||
_set_backoff(is_mock, 0)
|
||||
logger.info(f"✅ {mode} 토큰 발급 완료 | 만료: {exp} | 앞12자: {token[:12]}…")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ {mode} 토큰 발급 예외: {e}")
|
||||
_set_backoff(is_mock, GENERIC_FAIL_BACKOFF_S)
|
||||
return False
|
||||
|
||||
|
||||
@@ -332,6 +386,8 @@ class KisTokenManager:
|
||||
1. 메모리 캐시 유효 → 즉시 반환 (파일 읽기 없음)
|
||||
2. 메모리 만료 → 파일 재로드 (다른 프로세스가 갱신했을 수 있음)
|
||||
3. 파일도 만료 → ensure_token() 으로 신규 발급 + 파일/메모리 갱신
|
||||
4. 발급 백오프 중이면 (EGW00133 직후 등) 만료된 토큰이라도 반환
|
||||
→ 호출자가 None 받고 또 호출 → 또 None ... 무한루프 방지
|
||||
"""
|
||||
with self._lock:
|
||||
if self._is_valid():
|
||||
@@ -342,6 +398,16 @@ class KisTokenManager:
|
||||
logger.info("🔑 %s 토큰 파일 갱신 감지 → 메모리 갱신", self._mode_str)
|
||||
return self._token
|
||||
|
||||
# 백오프 중이면 발급 시도 자체를 안 함 (호출자 폭주 차단)
|
||||
allowed, wait_s = _can_issue_now(self._is_mock)
|
||||
if not allowed:
|
||||
logger.debug(
|
||||
"⏸ %s 토큰 발급 백오프 중 (%.1fs 남음) → 만료 캐시 토큰 임시 반환",
|
||||
self._mode_str, wait_s,
|
||||
)
|
||||
# 만료 토큰이라도 반환 (None 보다 안전 — 호출자 None 폭주 방지)
|
||||
return self._token
|
||||
|
||||
# 파일도 만료 → 잠금 기반 신규 발급
|
||||
ok = ensure_token(self._is_mock)
|
||||
if ok:
|
||||
@@ -352,7 +418,10 @@ class KisTokenManager:
|
||||
self._mode_str,
|
||||
(self._token[:8] if self._token else "N/A"),
|
||||
)
|
||||
return self._token if ok else None
|
||||
return self._token
|
||||
# 발급 실패 — 백오프는 _issue_token 안에서 이미 설정됨.
|
||||
# 만료된 메모리 토큰이라도 있으면 반환 (호출자가 None 보고 또 두드리는 것 방지).
|
||||
return self._token
|
||||
|
||||
def status(self) -> dict:
|
||||
"""현재 상태 반환 (get_token_status 래퍼, 남은 시간 포함)"""
|
||||
|
||||
Reference in New Issue
Block a user