브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -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 래퍼, 남은 시간 포함)"""