-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
fix(qqofficial): bound outbound HTTP concurrency #9863
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
Open
whatevertogo
wants to merge
3
commits into
AstrBotDevs:master
Choose a base branch
from
whatevertogo:whatevertogo/qqofficial-outbound-backpressure
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
279 changes: 279 additions & 0 deletions
279
astrbot/core/platform/sources/qqofficial/qqofficial_http.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,279 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from collections.abc import AsyncIterator | ||
| from contextlib import asynccontextmanager | ||
| from typing import Any | ||
|
|
||
| import aiohttp | ||
| from botpy.errors import HttpErrorDict, ServerError | ||
| from botpy.http import BotHttp, Route | ||
|
|
||
| from astrbot.api import logger | ||
| from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi | ||
|
|
||
| _CONNECTION_LIMIT = 32 | ||
| _CONNECTION_LIMIT_PER_HOST = 16 | ||
| _MAX_CONCURRENT_REQUESTS = 16 | ||
| _MAX_PENDING_REQUESTS = 64 | ||
|
whatevertogo marked this conversation as resolved.
|
||
| _QUEUE_TIMEOUT_SECONDS = 5.0 | ||
| _SUCCESS_STATUS_CODES = {200, 202, 204} | ||
|
|
||
|
|
||
| async def _parse_response(response: aiohttp.ClientResponse) -> Any: | ||
| """Parse one QQ API response using qq-botpy's status-to-error mapping. | ||
|
|
||
| Args: | ||
| response: Completed aiohttp response. | ||
|
|
||
| Returns: | ||
| Parsed JSON or text response payload. | ||
|
|
||
| Raises: | ||
| RuntimeError: The QQ API returns a non-success status. | ||
| """ | ||
| content_type = response.headers.get("content-type") | ||
| if not content_type: | ||
| data = None | ||
| else: | ||
| try: | ||
| data = ( | ||
| await response.json() | ||
| if content_type.startswith("application/json") | ||
| else await response.text() | ||
| ) | ||
| except ValueError: | ||
| data = None | ||
|
|
||
| if response.status in _SUCCESS_STATUS_CODES: | ||
| return data | ||
|
|
||
| message = data.get("message") if isinstance(data, dict) else str(data) | ||
| error_type = HttpErrorDict.get(response.status, ServerError) | ||
| raise error_type(msg=message) from None | ||
|
|
||
|
|
||
| class QQOfficialHttpClosedError(RuntimeError): | ||
| """Raised when an outbound request starts after adapter shutdown.""" | ||
|
|
||
|
|
||
| class QQOfficialHttpOverloadedError(RuntimeError): | ||
| """Raised when the bounded outbound request queue is full or times out.""" | ||
|
|
||
|
|
||
| class QQOfficialHttp(BotHttp): | ||
| """QQ Bot HTTP transport with reusable connections and bounded concurrency. | ||
|
|
||
| Args: | ||
| timeout: Total timeout in seconds for one HTTP attempt. | ||
| is_sandbox: Whether to use QQ's sandbox endpoint. | ||
| app_id: Optional QQ bot application ID. | ||
| secret: Optional QQ bot secret. | ||
| max_concurrent_requests: Maximum active HTTP attempts for one bot. | ||
| max_pending_requests: Maximum active and queued attempts for one bot. | ||
| queue_timeout: Maximum seconds a request may wait for an execution slot. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| timeout: int, | ||
| is_sandbox: bool = False, | ||
| app_id: str | None = None, | ||
| secret: str | None = None, | ||
| *, | ||
| max_concurrent_requests: int = _MAX_CONCURRENT_REQUESTS, | ||
| max_pending_requests: int = _MAX_PENDING_REQUESTS, | ||
| queue_timeout: float = _QUEUE_TIMEOUT_SECONDS, | ||
| ) -> None: | ||
| super().__init__( | ||
| timeout=timeout, | ||
| is_sandbox=is_sandbox, | ||
| app_id=app_id, | ||
| secret=secret, | ||
| ) | ||
| if max_concurrent_requests <= 0: | ||
| raise ValueError("max_concurrent_requests must be positive") | ||
| if max_pending_requests < max_concurrent_requests: | ||
| raise ValueError( | ||
| "max_pending_requests must be greater than or equal to " | ||
| "max_concurrent_requests" | ||
| ) | ||
| if queue_timeout <= 0: | ||
| raise ValueError("queue_timeout must be positive") | ||
|
|
||
| self._closing = False | ||
| self._session_lock = asyncio.Lock() | ||
| self._pending_lock = asyncio.Lock() | ||
| self._request_slots = asyncio.Semaphore(max_concurrent_requests) | ||
| self._max_pending_requests = max_pending_requests | ||
| self._queue_timeout = queue_timeout | ||
| self._pending_requests = 0 | ||
| self._request_tasks: set[asyncio.Task[Any]] = set() | ||
|
|
||
| async def check_session(self) -> None: | ||
| """Refresh authorization and create one reusable HTTP session if needed. | ||
|
|
||
| Raises: | ||
| QQOfficialHttpClosedError: The adapter is shutting down. | ||
| RuntimeError: The QQ bot token is not initialized. | ||
| """ | ||
| if self._closing: | ||
| raise QQOfficialHttpClosedError("QQ Official HTTP transport is closed") | ||
| if self._token is None: | ||
| raise RuntimeError("QQ Official HTTP token is not initialized") | ||
|
|
||
| async with self._session_lock: | ||
| if self._closing: | ||
| raise QQOfficialHttpClosedError("QQ Official HTTP transport is closed") | ||
| await self._token.check_token() | ||
| if self._closing: | ||
| raise QQOfficialHttpClosedError("QQ Official HTTP transport is closed") | ||
| self._headers = { | ||
| "Authorization": self._token.get_string(), | ||
| "X-Union-Appid": self._token.app_id, | ||
| } | ||
| if self._session is None or self._session.closed: | ||
| self._session = aiohttp.ClientSession( | ||
| connector=aiohttp.TCPConnector( | ||
| limit=_CONNECTION_LIMIT, | ||
| limit_per_host=_CONNECTION_LIMIT_PER_HOST, | ||
| ssl=build_ssl_context_with_certifi(), | ||
| keepalive_timeout=30, | ||
| ttl_dns_cache=300, | ||
| ) | ||
| ) | ||
|
|
||
| @asynccontextmanager | ||
| async def request_slot(self) -> AsyncIterator[None]: | ||
| """Reserve one bounded outbound request slot. | ||
|
|
||
| Yields: | ||
| Control while the caller owns a request slot. | ||
|
|
||
| Raises: | ||
| QQOfficialHttpClosedError: The adapter is shutting down. | ||
| QQOfficialHttpOverloadedError: The queue is full or its wait times out. | ||
| """ | ||
| if self._closing: | ||
| raise QQOfficialHttpClosedError("QQ Official HTTP transport is closed") | ||
|
|
||
| task = asyncio.current_task() | ||
| if task is None: | ||
| raise RuntimeError("QQ Official HTTP request requires an asyncio task") | ||
| self._request_tasks.add(task) | ||
|
|
||
| counted = False | ||
| acquired = False | ||
| try: | ||
| async with self._pending_lock: | ||
| if self._pending_requests >= self._max_pending_requests: | ||
| raise QQOfficialHttpOverloadedError( | ||
| "QQ Official outbound request queue is full" | ||
| ) | ||
| self._pending_requests += 1 | ||
| counted = True | ||
|
|
||
| try: | ||
| await asyncio.wait_for( | ||
| self._request_slots.acquire(), | ||
| timeout=self._queue_timeout, | ||
| ) | ||
| acquired = True | ||
| except TimeoutError as exc: | ||
|
whatevertogo marked this conversation as resolved.
Outdated
|
||
| raise QQOfficialHttpOverloadedError( | ||
| "QQ Official outbound request queue timed out" | ||
| ) from exc | ||
|
|
||
| if self._closing: | ||
| raise QQOfficialHttpClosedError("QQ Official HTTP transport is closed") | ||
| yield | ||
| finally: | ||
| if acquired: | ||
| self._request_slots.release() | ||
| if counted: | ||
| async with self._pending_lock: | ||
| self._pending_requests -= 1 | ||
| self._request_tasks.discard(task) | ||
|
|
||
| async def request(self, route: Route, retry_time: int = 0, **kwargs: Any) -> Any: | ||
| """Execute one HTTP attempt without qq-botpy's nested retry loop. | ||
|
|
||
| Args: | ||
| route: QQ API route. | ||
| retry_time: Unused qq-botpy compatibility argument. | ||
| **kwargs: Arguments forwarded to ``aiohttp.ClientSession.request``. | ||
|
|
||
| Returns: | ||
| Parsed QQ API response. | ||
|
|
||
| Raises: | ||
| QQOfficialHttpClosedError: The adapter is shutting down. | ||
| QQOfficialHttpOverloadedError: The outbound queue is overloaded. | ||
| aiohttp.ClientError: The HTTP attempt fails. | ||
| TimeoutError: The HTTP attempt times out. | ||
| """ | ||
| del retry_time | ||
| if "json" in kwargs: | ||
| json_payload = kwargs["json"] | ||
| file_image = json_payload.get("file_image") | ||
| if file_image and isinstance(file_image, bytes): | ||
| form_data = aiohttp.FormData() | ||
| for key, value in kwargs.pop("json").items(): | ||
| if not value or isinstance(value, dict): | ||
| continue | ||
| form_data.add_field(key, value) | ||
| kwargs["data"] = form_data | ||
|
|
||
| async with self.request_slot(): | ||
| await self.check_session() | ||
| route.is_sandbox = self.is_sandbox | ||
| session = self._session | ||
| if session is None: | ||
| raise RuntimeError("QQ Official HTTP session is unavailable") | ||
|
|
||
| try: | ||
| async with session.request( | ||
| method=route.method, | ||
| url=route.url, | ||
| headers=self._headers, | ||
| timeout=aiohttp.ClientTimeout(total=self.timeout), | ||
| **kwargs, | ||
| ) as response: | ||
| return await _parse_response(response) | ||
| except asyncio.TimeoutError: | ||
| logger.warning( | ||
| "[QQOfficial] HTTP request timed out: method=%s route=%s", | ||
| route.method, | ||
| route.path, | ||
| ) | ||
| raise | ||
| except ConnectionResetError: | ||
| logger.warning( | ||
| "[QQOfficial] HTTP connection reset: method=%s route=%s", | ||
| route.method, | ||
| route.path, | ||
| ) | ||
| raise | ||
|
|
||
| async def close(self) -> None: | ||
| """Stop queued/in-flight requests and permanently close the transport.""" | ||
| if self._closing: | ||
| return | ||
| self._closing = True | ||
|
|
||
| current_task = asyncio.current_task() | ||
| request_tasks = [ | ||
| task | ||
| for task in self._request_tasks | ||
| if task is not current_task and not task.done() | ||
| ] | ||
| for task in request_tasks: | ||
| task.cancel() | ||
| if request_tasks: | ||
| await asyncio.gather(*request_tasks, return_exceptions=True) | ||
|
|
||
| async with self._session_lock: | ||
| session = self._session | ||
| self._session = None | ||
| if session is not None and not session.closed: | ||
| await session.close() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.