-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix: retry qq_official startup after gateway timeout #6940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,6 +99,8 @@ def _commit(self, abm: AstrBotMessage) -> None: | |
|
|
||
| @register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器") | ||
| class QQOfficialPlatformAdapter(Platform): | ||
| STARTUP_RETRY_DELAY_SECONDS = 5 | ||
|
|
||
| def __init__( | ||
| self, | ||
| platform_config: dict, | ||
|
|
@@ -123,18 +125,47 @@ def __init__( | |
| public_guild_messages=True, | ||
| direct_message=guild_dm, | ||
| ) | ||
| self.client = botClient( | ||
| self._shutdown_event = asyncio.Event() | ||
| self.client = self._create_client() | ||
|
|
||
| self._session_last_message_id: dict[str, str] = {} | ||
| self._session_scene: dict[str, str] = {} | ||
|
|
||
| self.test_mode = os.environ.get("TEST_MODE", "off") == "on" | ||
|
|
||
| def _create_client(self) -> botClient: | ||
| client = botClient( | ||
| intents=self.intents, | ||
| bot_log=False, | ||
| timeout=20, | ||
| ) | ||
| client.set_platform(self) | ||
| return client | ||
|
|
||
| self.client.set_platform(self) | ||
|
|
||
| self._session_last_message_id: dict[str, str] = {} | ||
| self._session_scene: dict[str, str] = {} | ||
| @staticmethod | ||
| def _should_retry_startup_error(error: Exception) -> bool: | ||
| if isinstance(error, (asyncio.TimeoutError, ConnectionError, OSError)): | ||
| return True | ||
| if isinstance(error, TypeError): | ||
| error_msg = str(error) | ||
| return "NoneType" in error_msg and "subscriptable" in error_msg | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking for a specific While this may be a necessary workaround, it would be beneficial to add a comment explaining why this specific error message is being checked. This will provide crucial context for future maintainers. For example: # The botpy library may raise a TypeError("'NoneType' object is not subscriptable")
# on a transient gateway timeout. We catch this specific error to enable retries.
# See issue: [link to botpy issue if any] |
||
| return False | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| async def _close_client(self) -> None: | ||
| try: | ||
| await self.client.close() | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception as e: | ||
| logger.warning( | ||
| "qq_official(%s): close client failed during recovery: %s", | ||
| self.meta().id, | ||
| e, | ||
| ) | ||
|
|
||
| self.test_mode = os.environ.get("TEST_MODE", "off") == "on" | ||
| async def _recreate_client(self) -> None: | ||
| await self._close_client() | ||
| self.client = self._create_client() | ||
|
|
||
| async def send_by_session( | ||
| self, | ||
|
|
@@ -500,12 +531,48 @@ def _parse_from_qqofficial( | |
| abm.self_id = "qq_official" | ||
| return abm | ||
|
|
||
| def run(self): | ||
| return self.client.start(appid=self.appid, secret=self.secret) | ||
| async def run(self) -> None: | ||
| try: | ||
| while not self._shutdown_event.is_set(): | ||
| try: | ||
| await self.client.start(appid=self.appid, secret=self.secret) | ||
| if self._shutdown_event.is_set(): | ||
| break | ||
| logger.warning( | ||
| "qq_official(%s): client stopped unexpectedly, restarting in %ss", | ||
| self.meta().id, | ||
| self.STARTUP_RETRY_DELAY_SECONDS, | ||
| ) | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception as e: | ||
| if not self._should_retry_startup_error(e): | ||
| raise | ||
| if self._shutdown_event.is_set(): | ||
| break | ||
| logger.warning( | ||
| "qq_official(%s): startup failed, retrying in %ss: %s", | ||
| self.meta().id, | ||
| self.STARTUP_RETRY_DELAY_SECONDS, | ||
| e, | ||
| ) | ||
|
|
||
| await self._recreate_client() | ||
|
|
||
| try: | ||
| await asyncio.wait_for( | ||
| self._shutdown_event.wait(), | ||
| timeout=self.STARTUP_RETRY_DELAY_SECONDS, | ||
| ) | ||
| except asyncio.TimeoutError: | ||
| continue | ||
| finally: | ||
| await self._close_client() | ||
|
|
||
| def get_client(self) -> botClient: | ||
| return self.client | ||
|
|
||
| async def terminate(self) -> None: | ||
| await self.client.close() | ||
| self._shutdown_event.set() | ||
| await self._close_client() | ||
| logger.info("QQ 官方机器人接口 适配器已被优雅地关闭") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import asyncio | ||
| from types import SimpleNamespace | ||
| from unittest.mock import AsyncMock | ||
|
|
||
| import pytest | ||
|
|
||
| from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( | ||
| QQOfficialPlatformAdapter, | ||
| ) | ||
|
|
||
|
|
||
| def _platform_config() -> dict: | ||
| return { | ||
| "id": "qq-official-test", | ||
| "appid": "appid", | ||
| "secret": "secret", | ||
| "enable_group_c2c": True, | ||
| "enable_guild_direct_message": True, | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_qqofficial_run_retries_after_gateway_timeout(monkeypatch): | ||
| first_client = SimpleNamespace( | ||
| start=AsyncMock( | ||
| side_effect=TypeError("'NoneType' object is not subscriptable") | ||
| ), | ||
| close=AsyncMock(), | ||
| ) | ||
| adapter_holder: dict[str, QQOfficialPlatformAdapter] = {} | ||
|
|
||
| async def second_start(*args, **kwargs): | ||
| adapter_holder["adapter"]._shutdown_event.set() | ||
| return None | ||
|
|
||
| second_client = SimpleNamespace( | ||
| start=AsyncMock(side_effect=second_start), | ||
| close=AsyncMock(), | ||
| ) | ||
| clients = iter([first_client, second_client]) | ||
| monkeypatch.setattr( | ||
| QQOfficialPlatformAdapter, | ||
| "_create_client", | ||
| lambda self: next(clients), | ||
| ) | ||
|
|
||
| adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue()) | ||
| adapter_holder["adapter"] = adapter | ||
| adapter.STARTUP_RETRY_DELAY_SECONDS = 0 | ||
|
|
||
| await adapter.run() | ||
|
|
||
| first_client.start.assert_awaited_once_with(appid="appid", secret="secret") | ||
| first_client.close.assert_awaited_once() | ||
| second_client.start.assert_awaited_once_with(appid="appid", secret="secret") | ||
| second_client.close.assert_awaited_once() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_qqofficial_run_reraises_non_retryable_error(monkeypatch): | ||
| client = SimpleNamespace( | ||
| start=AsyncMock(side_effect=ValueError("invalid credentials")), | ||
| close=AsyncMock(), | ||
| ) | ||
| monkeypatch.setattr( | ||
| QQOfficialPlatformAdapter, | ||
| "_create_client", | ||
| lambda self: client, | ||
| ) | ||
|
|
||
| adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue()) | ||
|
|
||
| with pytest.raises(ValueError, match="invalid credentials"): | ||
| await adapter.run() | ||
|
|
||
| client.start.assert_awaited_once_with(appid="appid", secret="secret") | ||
| client.close.assert_awaited_once() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_qqofficial_run_propagates_cancelled_error(monkeypatch): | ||
| client = SimpleNamespace( | ||
| start=AsyncMock(side_effect=asyncio.CancelledError()), | ||
| close=AsyncMock(), | ||
| ) | ||
| monkeypatch.setattr( | ||
| QQOfficialPlatformAdapter, | ||
| "_create_client", | ||
| lambda self: client, | ||
| ) | ||
|
|
||
| adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue()) | ||
|
|
||
| with pytest.raises(asyncio.CancelledError): | ||
| await adapter.run() | ||
|
|
||
| client.close.assert_awaited_once() |
Uh oh!
There was an error while loading. Please reload this page.