fix: Telegram 409 다중 인스턴스 충돌 시 WARNING + 30초 백오프 (#180) #185

Merged
jihoson merged 2 commits from feature/issue-180-telegram-instance-lock into main 2026-02-20 09:52:15 +09:00
2 changed files with 75 additions and 3 deletions
Showing only changes of commit 77577f3f4d - Show all commits

View File

@@ -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()

View File

@@ -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."""