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