fix: Telegram 409 감지 시 백오프 대신 polling 즉시 종료 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
409 충돌 감지 시 30초 백오프 후 재시도하는 방식에서 _running = False로 polling을 즉시 중단하는 방식으로 변경. 다중 인스턴스가 실행 중인 경우 재시도는 의미 없고 충돌만 반복됨. 이제 409 발생 시 이 프로세스의 Telegram 명령어 polling을 완전히 비활성화. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -516,7 +516,6 @@ class TelegramCommandHandler:
|
|||||||
self._last_update_id = 0
|
self._last_update_id = 0
|
||||||
self._polling_task: asyncio.Task[None] | None = None
|
self._polling_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._conflict_backoff_until: float = 0.0 # epoch time; skip polling until then
|
|
||||||
|
|
||||||
def register_command(
|
def register_command(
|
||||||
self, command: str, handler: Callable[[], Awaitable[None]]
|
self, command: str, handler: Callable[[], Awaitable[None]]
|
||||||
@@ -575,12 +574,6 @@ class TelegramCommandHandler:
|
|||||||
async def _poll_loop(self) -> None:
|
async def _poll_loop(self) -> None:
|
||||||
"""Main polling loop that fetches updates."""
|
"""Main polling loop that fetches updates."""
|
||||||
while self._running:
|
while self._running:
|
||||||
# Skip this iteration while a conflict backoff is active
|
|
||||||
now = asyncio.get_event_loop().time()
|
|
||||||
if now < self._conflict_backoff_until:
|
|
||||||
await asyncio.sleep(self._polling_interval)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updates = await self._get_updates()
|
updates = await self._get_updates()
|
||||||
for update in updates:
|
for update in updates:
|
||||||
@@ -612,15 +605,13 @@ class TelegramCommandHandler:
|
|||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
if resp.status == 409:
|
if resp.status == 409:
|
||||||
# Another bot instance is already polling — back off to reduce conflict.
|
# Another bot instance is already polling — stop this poller entirely.
|
||||||
_conflict_backoff_secs = 30.0
|
# Retrying would keep conflicting with the other instance.
|
||||||
self._conflict_backoff_until = (
|
self._running = False
|
||||||
asyncio.get_event_loop().time() + _conflict_backoff_secs
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Telegram conflict (409): another instance is polling. "
|
"Telegram conflict (409): another instance is already polling. "
|
||||||
"Backing off %.0fs. Ensure only one bot instance runs at a time.",
|
"Disabling Telegram commands for this process. "
|
||||||
_conflict_backoff_secs,
|
"Ensure only one instance of The Ouroboros is running at a time.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -877,10 +877,11 @@ class TestGetUpdates:
|
|||||||
assert updates == []
|
assert updates == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_updates_409_sets_conflict_backoff(self) -> None:
|
async def test_get_updates_409_stops_polling(self) -> None:
|
||||||
"""409 Conflict response sets conflict_backoff_until and returns empty list."""
|
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
|
||||||
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
handler = TelegramCommandHandler(client)
|
handler = TelegramCommandHandler(client)
|
||||||
|
handler._running = True # simulate active poller
|
||||||
|
|
||||||
mock_resp = AsyncMock()
|
mock_resp = AsyncMock()
|
||||||
mock_resp.status = 409
|
mock_resp.status = 409
|
||||||
@@ -894,40 +895,34 @@ class TestGetUpdates:
|
|||||||
updates = await handler._get_updates()
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
assert updates == []
|
assert updates == []
|
||||||
assert handler._conflict_backoff_until > 0 # backoff was set
|
assert handler._running is False # poller stopped
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_loop_skips_during_conflict_backoff(self) -> None:
|
async def test_poll_loop_exits_after_409(self) -> None:
|
||||||
"""_poll_loop skips _get_updates while conflict backoff is active."""
|
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
|
|
||||||
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
handler = TelegramCommandHandler(client)
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
# Set an active backoff (far in the future)
|
call_count = 0
|
||||||
handler._conflict_backoff_until = _asyncio.get_event_loop().time() + 600
|
|
||||||
|
|
||||||
get_updates_called = []
|
async def mock_get_updates_409() -> list[dict]:
|
||||||
|
nonlocal call_count
|
||||||
async def mock_get_updates() -> list[dict]:
|
call_count += 1
|
||||||
get_updates_called.append(True)
|
# Simulate 409 stopping the poller
|
||||||
|
handler._running = False
|
||||||
return []
|
return []
|
||||||
|
|
||||||
handler._get_updates = mock_get_updates # type: ignore[method-assign]
|
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
|
||||||
|
|
||||||
# Run one iteration of the poll loop then stop
|
|
||||||
handler._running = True
|
handler._running = True
|
||||||
task = _asyncio.create_task(handler._poll_loop())
|
task = _asyncio.create_task(handler._poll_loop())
|
||||||
await _asyncio.sleep(0.05)
|
await _asyncio.wait_for(task, timeout=2.0)
|
||||||
handler._running = False
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except _asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# _get_updates should NOT have been called while backoff is active
|
# _get_updates called exactly once, then loop exited
|
||||||
assert get_updates_called == []
|
assert call_count == 1
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
|
||||||
class TestCommandWithArgs:
|
class TestCommandWithArgs:
|
||||||
|
|||||||
Reference in New Issue
Block a user