From a56adcd34277905392aadfd59445f2ef7003452b Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 5 Feb 2026 00:37:20 +0900 Subject: [PATCH] fix: add token refresh cooldown to prevent EGW00133 cascading failures (issue #54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents rapid retry attempts when token refresh hits KIS API's 1-per-minute rate limit (EGW00133: 접근토큰 발급 잠시 후 다시 시도하세요). Changes: - src/broker/kis_api.py:58-61 - Add cooldown tracking variables - src/broker/kis_api.py:102-111 - Enforce 60s cooldown between refresh attempts - tests/test_broker.py - Add cooldown behavior tests Before: - Token refresh fails with EGW00133 - Every API call triggers another refresh attempt - Cascading failures, system unusable After: - Token refresh fails with EGW00133 (first attempt) - Subsequent attempts blocked for 60s with clear error - System knows to wait, prevents cascading failures Test Results: - All 285 tests pass - New tests verify cooldown behavior - Existing token management tests still pass Implementation Details: - Cooldown starts on refresh attempt (not just failures) - Clear error message tells caller how long to wait - Compatible with existing token expiry + locking logic Co-Authored-By: Claude Sonnet 4.5 --- src/broker/kis_api.py | 14 ++++++++++ tests/test_broker.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/broker/kis_api.py b/src/broker/kis_api.py index bfec86b..990c340 100644 --- a/src/broker/kis_api.py +++ b/src/broker/kis_api.py @@ -56,6 +56,8 @@ class KISBroker: self._access_token: str | None = None self._token_expires_at: float = 0.0 self._token_lock = asyncio.Lock() + self._last_refresh_attempt: float = 0.0 + self._refresh_cooldown: float = 60.0 # Seconds (matches KIS 1/minute limit) self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS) def _get_session(self) -> aiohttp.ClientSession: @@ -98,7 +100,19 @@ class KISBroker: if self._access_token and now < self._token_expires_at: return self._access_token + # Check cooldown period (prevents hitting EGW00133: 1/minute limit) + time_since_last_attempt = now - self._last_refresh_attempt + if time_since_last_attempt < self._refresh_cooldown: + remaining = self._refresh_cooldown - time_since_last_attempt + error_msg = ( + f"Token refresh on cooldown. " + f"Retry in {remaining:.1f}s (KIS allows 1/minute)" + ) + logger.warning(error_msg) + raise ConnectionError(error_msg) + logger.info("Refreshing KIS access token") + self._last_refresh_attempt = now session = self._get_session() url = f"{self._base_url}/oauth2/tokenP" body = { diff --git a/tests/test_broker.py b/tests/test_broker.py index bacde6d..e5b5594 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -89,6 +89,70 @@ class TestTokenManagement: await broker.close() + @pytest.mark.asyncio + async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings): + """Token refresh should enforce cooldown after failure (issue #54).""" + broker = KISBroker(settings) + broker._refresh_cooldown = 2.0 # Short cooldown for testing + + # First refresh attempt fails with 403 (EGW00133) + mock_resp_403 = AsyncMock() + mock_resp_403.status = 403 + mock_resp_403.text = AsyncMock( + return_value='{"error_code":"EGW00133","error_description":"접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"}' + ) + mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403) + mock_resp_403.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp_403): + # First attempt should fail with 403 + with pytest.raises(ConnectionError, match="Token refresh failed"): + await broker._ensure_token() + + # Second attempt within cooldown should fail with cooldown error + with pytest.raises(ConnectionError, match="Token refresh on cooldown"): + await broker._ensure_token() + + await broker.close() + + @pytest.mark.asyncio + async def test_token_refresh_allowed_after_cooldown(self, settings): + """Token refresh should be allowed after cooldown period expires.""" + broker = KISBroker(settings) + broker._refresh_cooldown = 0.1 # Very short cooldown for testing + + # First attempt fails + mock_resp_403 = AsyncMock() + mock_resp_403.status = 403 + mock_resp_403.text = AsyncMock(return_value='{"error_code":"EGW00133"}') + mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403) + mock_resp_403.__aexit__ = AsyncMock(return_value=False) + + # Second attempt succeeds + mock_resp_200 = AsyncMock() + mock_resp_200.status = 200 + mock_resp_200.json = AsyncMock( + return_value={ + "access_token": "tok_after_cooldown", + "expires_in": 86400, + } + ) + mock_resp_200.__aenter__ = AsyncMock(return_value=mock_resp_200) + mock_resp_200.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp_403): + with pytest.raises(ConnectionError, match="Token refresh failed"): + await broker._ensure_token() + + # Wait for cooldown to expire + await asyncio.sleep(0.15) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp_200): + token = await broker._ensure_token() + assert token == "tok_after_cooldown" + + await broker.close() + # --------------------------------------------------------------------------- # Network Error Handling