Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a56adcd342 |
@@ -56,6 +56,8 @@ class KISBroker:
|
|||||||
self._access_token: str | None = None
|
self._access_token: str | None = None
|
||||||
self._token_expires_at: float = 0.0
|
self._token_expires_at: float = 0.0
|
||||||
self._token_lock = asyncio.Lock()
|
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)
|
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
|
||||||
|
|
||||||
def _get_session(self) -> aiohttp.ClientSession:
|
def _get_session(self) -> aiohttp.ClientSession:
|
||||||
@@ -98,7 +100,19 @@ class KISBroker:
|
|||||||
if self._access_token and now < self._token_expires_at:
|
if self._access_token and now < self._token_expires_at:
|
||||||
return self._access_token
|
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")
|
logger.info("Refreshing KIS access token")
|
||||||
|
self._last_refresh_attempt = now
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
url = f"{self._base_url}/oauth2/tokenP"
|
url = f"{self._base_url}/oauth2/tokenP"
|
||||||
body = {
|
body = {
|
||||||
|
|||||||
@@ -549,9 +549,7 @@ async def run(settings: Settings) -> None:
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass # Normal — timeout means it's time for next cycle
|
pass # Normal — timeout means it's time for next cycle
|
||||||
finally:
|
finally:
|
||||||
# Clean up resources
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
await telegram.close()
|
|
||||||
db_conn.close()
|
db_conn.close()
|
||||||
logger.info("The Ouroboros rests.")
|
logger.info("The Ouroboros rests.")
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,70 @@ class TestTokenManagement:
|
|||||||
|
|
||||||
await broker.close()
|
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
|
# Network Error Handling
|
||||||
|
|||||||
Reference in New Issue
Block a user