Compare commits

..

2 Commits

Author SHA1 Message Date
agentson
aceba86186 fix: Telegram 409 감지 시 백오프 대신 polling 즉시 종료 (#180)
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>
2026-02-20 09:35:33 +09:00
agentson
77577f3f4d fix: Telegram 409 충돌 시 WARNING 로그 + 30초 백오프 적용 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
다중 인스턴스 실행 시 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 <noreply@anthropic.com>
2026-02-20 09:31:04 +09:00
4 changed files with 63 additions and 36 deletions

View File

@@ -1300,18 +1300,10 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
if not settings.DASHBOARD_ENABLED: if not settings.DASHBOARD_ENABLED:
return None return None
# Validate dependencies before spawning the thread so startup failures are
# reported synchronously (avoids the misleading "started" → "failed" log pair).
try:
import uvicorn # noqa: F401
from src.dashboard import create_dashboard_app # noqa: F401
except ImportError as exc:
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
return None
def _serve() -> None: def _serve() -> None:
try: try:
import uvicorn import uvicorn
from src.dashboard import create_dashboard_app from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH) app = create_dashboard_app(settings.DB_PATH)
@@ -1322,7 +1314,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
log_level="info", log_level="info",
) )
except Exception as exc: except Exception as exc:
logger.warning("Dashboard server stopped unexpectedly: %s", exc) logger.warning("Dashboard server failed to start: %s", exc)
thread = threading.Thread( thread = threading.Thread(
target=_serve, target=_serve,

View File

@@ -604,6 +604,16 @@ class TelegramCommandHandler:
async with session.post(url, json=payload) as resp: async with session.post(url, json=payload) as resp:
if resp.status != 200: if resp.status != 200:
error_text = await resp.text() error_text = await resp.text()
if resp.status == 409:
# 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 already polling. "
"Disabling Telegram commands for this process. "
"Ensure only one instance of The Ouroboros is running at a time.",
)
else:
logger.error( logger.error(
"getUpdates API error (status=%d): %s", resp.status, error_text "getUpdates API error (status=%d): %s", resp.status, error_text
) )

View File

@@ -2194,29 +2194,6 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
mock_thread.start.assert_called_once() mock_thread.start.assert_called_once()
def test_start_dashboard_server_returns_none_when_uvicorn_missing() -> None:
"""Returns None (no thread) and logs a warning when uvicorn is not installed."""
settings = Settings(
KIS_APP_KEY="test_key",
KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key",
DASHBOARD_ENABLED=True,
)
import builtins
real_import = builtins.__import__
def mock_import(name: str, *args: object, **kwargs: object) -> object:
if name == "uvicorn":
raise ImportError("No module named 'uvicorn'")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
thread = _start_dashboard_server(settings)
assert thread is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# market_outlook BUY confidence threshold tests (#173) # market_outlook BUY confidence threshold tests (#173)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -876,6 +876,54 @@ class TestGetUpdates:
assert updates == [] assert updates == []
@pytest.mark.asyncio
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
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._running is False # poller stopped
@pytest.mark.asyncio
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)
call_count = 0
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_409 # type: ignore[method-assign]
handler._running = True
task = _asyncio.create_task(handler._poll_loop())
await _asyncio.wait_for(task, timeout=2.0)
# _get_updates called exactly once, then loop exited
assert call_count == 1
assert handler._running is False
class TestCommandWithArgs: class TestCommandWithArgs:
"""Test register_command_with_args and argument dispatch.""" """Test register_command_with_args and argument dispatch."""