From aceba86186da949ab6e4a33c0a961338c47df228 Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 09:35:33 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Telegram=20409=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=B1=EC=98=A4=ED=94=84=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?polling=20=EC=A6=89=EC=8B=9C=20=EC=A2=85=EB=A3=8C=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 409 충돌 감지 시 30초 백오프 후 재시도하는 방식에서 _running = False로 polling을 즉시 중단하는 방식으로 변경. 다중 인스턴스가 실행 중인 경우 재시도는 의미 없고 충돌만 반복됨. 이제 409 발생 시 이 프로세스의 Telegram 명령어 polling을 완전히 비활성화. Co-Authored-By: Claude Sonnet 4.6 --- src/notifications/telegram_client.py | 21 +++++---------- tests/test_telegram_commands.py | 39 ++++++++++++---------------- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index da430fe..bc4eca4 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -516,7 +516,6 @@ class TelegramCommandHandler: self._last_update_id = 0 self._polling_task: asyncio.Task[None] | None = None self._running = False - self._conflict_backoff_until: float = 0.0 # epoch time; skip polling until then def register_command( self, command: str, handler: Callable[[], Awaitable[None]] @@ -575,12 +574,6 @@ class TelegramCommandHandler: async def _poll_loop(self) -> None: """Main polling loop that fetches updates.""" 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: updates = await self._get_updates() for update in updates: @@ -612,15 +605,13 @@ class TelegramCommandHandler: if resp.status != 200: error_text = await resp.text() if resp.status == 409: - # Another bot instance is already polling — back off to reduce conflict. - _conflict_backoff_secs = 30.0 - self._conflict_backoff_until = ( - asyncio.get_event_loop().time() + _conflict_backoff_secs - ) + # Another bot instance is already polling — stop this poller entirely. + # Retrying would keep conflicting with the other instance. + self._running = False logger.warning( - "Telegram conflict (409): another instance is polling. " - "Backing off %.0fs. Ensure only one bot instance runs at a time.", - _conflict_backoff_secs, + "Telegram conflict (409): another instance is already polling. " + "Disabling Telegram commands for this process. " + "Ensure only one instance of The Ouroboros is running at a time.", ) else: logger.error( diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index 16147fe..a184549 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -877,10 +877,11 @@ class TestGetUpdates: assert updates == [] @pytest.mark.asyncio - async def test_get_updates_409_sets_conflict_backoff(self) -> None: - """409 Conflict response sets conflict_backoff_until and returns empty list.""" + async def test_get_updates_409_stops_polling(self) -> None: + """409 Conflict response stops the poller (_running = False) and returns empty list.""" client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) handler = TelegramCommandHandler(client) + handler._running = True # simulate active poller mock_resp = AsyncMock() mock_resp.status = 409 @@ -894,40 +895,34 @@ class TestGetUpdates: updates = await handler._get_updates() assert updates == [] - assert handler._conflict_backoff_until > 0 # backoff was set + assert handler._running is False # poller stopped @pytest.mark.asyncio - async def test_poll_loop_skips_during_conflict_backoff(self) -> None: - """_poll_loop skips _get_updates while conflict backoff is active.""" + async def test_poll_loop_exits_after_409(self) -> None: + """_poll_loop exits naturally after _running is set to False by a 409 response.""" import asyncio as _asyncio client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) handler = TelegramCommandHandler(client) - # Set an active backoff (far in the future) - handler._conflict_backoff_until = _asyncio.get_event_loop().time() + 600 + call_count = 0 - get_updates_called = [] - - async def mock_get_updates() -> list[dict]: - get_updates_called.append(True) + async def mock_get_updates_409() -> list[dict]: + nonlocal call_count + call_count += 1 + # Simulate 409 stopping the poller + handler._running = False 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 task = _asyncio.create_task(handler._poll_loop()) - await _asyncio.sleep(0.05) - handler._running = False - task.cancel() - try: - await task - except _asyncio.CancelledError: - pass + await _asyncio.wait_for(task, timeout=2.0) - # _get_updates should NOT have been called while backoff is active - assert get_updates_called == [] + # _get_updates called exactly once, then loop exited + assert call_count == 1 + assert handler._running is False class TestCommandWithArgs: