From 77577f3f4d9461cf1221127b6be725a27db14dfe Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 09:31:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Telegram=20409=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=20=EC=8B=9C=20WARNING=20=EB=A1=9C=EA=B7=B8=20+=2030=EC=B4=88?= =?UTF-8?q?=20=EB=B0=B1=EC=98=A4=ED=94=84=20=EC=A0=81=EC=9A=A9=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다중 인스턴스 실행 시 Telegram getUpdates 409 응답을 ERROR가 아닌 WARNING으로 처리하고, 30초 동안 polling을 일시 중단하여 충돌을 완화. - _conflict_backoff_until 속성 추가 - 409 감지 시 명확한 "another instance is polling" 메시지 출력 - poll_loop에서 백오프 활성 중 polling 스킵 - TestGetUpdates에 409 관련 테스트 2개 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/notifications/telegram_client.py | 25 +++++++++++-- tests/test_telegram_commands.py | 53 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index ee61bce..da430fe 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -516,6 +516,7 @@ 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]] @@ -574,6 +575,12 @@ 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: @@ -604,9 +611,21 @@ class TelegramCommandHandler: async with session.post(url, json=payload) as resp: if resp.status != 200: error_text = await resp.text() - logger.error( - "getUpdates API error (status=%d): %s", resp.status, error_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 + ) + logger.warning( + "Telegram conflict (409): another instance is polling. " + "Backing off %.0fs. Ensure only one bot instance runs at a time.", + _conflict_backoff_secs, + ) + else: + logger.error( + "getUpdates API error (status=%d): %s", resp.status, error_text + ) return [] data = await resp.json() diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index bf9b437..16147fe 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -876,6 +876,59 @@ 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.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 409 + mock_resp.text = AsyncMock( + return_value='{"ok":false,"error_code":409,"description":"Conflict"}' + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp): + updates = await handler._get_updates() + + assert updates == [] + assert handler._conflict_backoff_until > 0 # backoff was set + + @pytest.mark.asyncio + async def test_poll_loop_skips_during_conflict_backoff(self) -> None: + """_poll_loop skips _get_updates while conflict backoff is active.""" + 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 + + get_updates_called = [] + + async def mock_get_updates() -> list[dict]: + get_updates_called.append(True) + return [] + + handler._get_updates = mock_get_updates # 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 + + # _get_updates should NOT have been called while backoff is active + assert get_updates_called == [] + class TestCommandWithArgs: """Test register_command_with_args and argument dispatch.""" From aceba86186da949ab6e4a33c0a961338c47df228 Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 09:35:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Telegram=20409=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=20=EC=8B=9C=20=EB=B0=B1=EC=98=A4=ED=94=84=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20polling=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: