Загрузка данных
import asyncio
import logging
import time
from typing import Any, Callable, List, Optional
import msgpack
import websockets
from websockets import InvalidStatus
from clients.signalr_negotiator import SignalRNegotiator
from constants.architecture_constants import WebSocketClientConstants as WS_Const
from utils.msgpack_utils.message_filters import is_desired_invocation_id, is_desired_type
from utils.msgpack_utils.msgpack_utils import encode_with_varint_prefix, parse_message
logger = logging.getLogger(__name__)
class WebSocketClient:
"""
Асинхронный ws-клиент для api-gateway по протоколу Async Api
"""
def __init__(
self,
host: str,
access_token: str,
x_user_id: str,
reconnect_interval: float = WS_Const.DEFAULT_RECONNECT_INTERVAL,
):
self._host = host
self._access_token = access_token
self._x_user_id = x_user_id
self._reconnect_interval = reconnect_interval
self._ws_url = f"wss://{host.rstrip('/')}{WS_Const.WS_HUBS}"
self._buffer = b""
self._next_id: int = WS_Const.START_INVOCATION_ID
self._ws: websockets.ClientConnection | None = None
self.recv_queue: asyncio.Queue[Any] = asyncio.Queue()
self._recv_task: asyncio.Task | None = None
self._ping_task: asyncio.Task | None = None
self._stop_event = asyncio.Event()
self._invocation_id: Optional[str] = None
self.suppress_recv_logging: bool = False
# Negotiate обязателен перед wss: без connectionToken api-gateway не поднимает консьюмеры
self._negotiator = SignalRNegotiator(host, access_token)
self._connection_id: Optional[str] = None
self._connection_token: Optional[str] = None
self._on_disconnected: Optional[Callable[[], None]] = None
# Счётчики для мониторинга соединения: рост принятых сообщений и пинги означают,
# что клиент подписан и соединение живо
self.ws_pings_sent: int = 0
self.ws_pings_failed: int = 0
self.messages_received: int = 0
self.reconnect_count: int = 0
@property
def invocation_id(self):
return self._invocation_id
@property
def connection_id(self) -> Optional[str]:
"""connectionId из negotiate: используется для диагностики соединения в логах."""
return self._connection_id
@property
def connection_token(self) -> Optional[str]:
"""connectionToken из negotiate: передаётся в wss URL как параметр id="""
return self._connection_token
@property
def is_connected(self) -> bool:
"""True, если wss-соединение установлено и приём сообщений активен."""
if self._ws is None:
return False
return self._recv_task is not None and not self._recv_task.done()
def set_disconnected_handler(self, handler: Optional[Callable[[], None]]) -> None:
"""
Регистрирует уведомление о потере соединения.
В момент обрыва тест может ничего не читать из очереди и о разрыве не узнает,
а авто-реконнект надо поднять немедленно, иначе api-gateway перестанет вычитывать сообщения подписок.
"""
self._on_disconnected = handler
def clear_queue(self):
"""
Очищает очередь путем пересоздания экземпляра класса очереди
"""
while not self.recv_queue.empty():
try:
self.recv_queue.get_nowait()
self.recv_queue.task_done()
except asyncio.QueueEmpty as message_empty:
logger.info(f"Очередь сообщений очищена: {message_empty}")
break
def drain_messages(self) -> List[Any]:
"""
Забирает все сообщения из очереди без блокирующего ожидания.
Единый API с фасадом постоянного соединения: хелперы сценариев вычитывают поток
одним способом независимо от того, разовое соединение или постоянное.
"""
messages: List[Any] = []
while not self.recv_queue.empty():
try:
messages.append(self.recv_queue.get_nowait())
except asyncio.QueueEmpty:
break
return messages
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
async def connect(self) -> None:
"""
Устанавливает соединение: negotiate -> wss по connectionToken -> handshake -> фоновые задачи.
Публичный метод нужен владельцу постоянного соединения: он управляет жизненным циклом
соединения сам и не использует контекстный менеджер.
"""
await self._connect_loop()
async def close(self) -> None:
"""Закрывает соединение, фоновые задачи и http-сессию negotiate."""
self._log_connection_summary()
self._stop_event.set()
await self._cancel_task(self._ping_task)
self._ping_task = None
await self._cancel_task(self._recv_task)
self._recv_task = None
if self._ws:
await self._ws.close()
self._negotiator.close()
# TODO: дергать ручку завершения сессии в LDS-4083
async def _handshake(self) -> None:
payload = WS_Const.HANDSHAKE_MESSAGE + WS_Const.RS.decode()
logger.debug(
f"Отправлен handshake: {payload}",
)
await self._ws.send(payload)
buf = self._buffer
finish = time.monotonic() + WS_Const.HANDSHAKE_WAITING
while time.monotonic() < finish:
chunk = await self._ws.recv()
logger.debug(f"Ответ на handshake: {chunk}")
buf += chunk.encode() if isinstance(chunk, str) else chunk
if WS_Const.RS in buf:
return
raise TimeoutError("Handshake timeout: не получили сообщение с разделителем RS за указанное время")
async def _connect_loop(self, timeout: float = WS_Const.WS_CONNECT_TIMEOUT_SECONDS) -> None:
"""
Цикл подключения с повторными попытками до наступления stop_event
или истечения WS_CONNECT_TIMEOUT_SECONDS.
"""
deadline = time.monotonic() + timeout
attempt = 0
transient_errors = (ConnectionError, OSError, asyncio.TimeoutError, InvalidStatus)
while not self._stop_event.is_set():
attempt += 1
try:
# Negotiate выполняется на каждую попытку подключения: connectionToken одноразовый
# и выдаётся под конкретное соединение
connection_token = await asyncio.to_thread(self._negotiate)
self.ws_request = self._build_ws_request_url(connection_token)
logger.info(f"Попытка подключения по wss: {self._ws_url}/?token=...&xUserId=...&id=...")
self._ws = await websockets.connect(
self.ws_request,
ping_interval=WS_Const.PING_INTERVAL,
ping_timeout=WS_Const.PING_TIMEOUT,
close_timeout=WS_Const.CLOSE_TIMEOUT,
)
# Handshake
await self._handshake()
# Запускаем приём сообщений и SignalR keep-alive в фоне
self._recv_task = asyncio.create_task(self._recv_loop())
self._ping_task = asyncio.create_task(self._ping_loop())
logger.info(f"Websocket connected (connectionId={self._connection_id})")
return
except transient_errors as exc:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
f"WSS hub не готов за {WS_Const.WS_CONNECT_TIMEOUT_SECONDS} с " f"(попыток: {attempt}): {exc}"
) from exc
status_info = ""
pause = self._reconnect_interval
if isinstance(exc, InvalidStatus):
response = getattr(exc, "response", None)
if response is not None:
status_info = f", HTTP {response.status_code}"
if response.status_code == WS_Const.NOT_FOUND_STATUS_CODE:
# 404 означает, что балансировщик увёл handshake не на тот узел
# api-gateway, где negotiate зарегистрировал connectionToken.
# Следующая попытка снова делает negotiate и получает новый токен,
# поэтому долгая пауза только теряет время прогона.
pause = WS_Const.LB_MISS_RETRY_INTERVAL_S
logger.warning(
"WSS подключение не установлено (попытка %s%s): %s. " "Повтор через %s с, осталось %.0f с",
attempt,
status_info,
exc,
pause,
remaining,
)
await asyncio.sleep(pause)
async def _recv_loop(self) -> None:
"""
Прием сообщений, парсинг и отправка в очередь.
"""
assert self._ws is not None
while not self._stop_event.is_set():
try:
chunk = await self._ws.recv()
if not self.suppress_recv_logging:
logger.debug(f"Сырые биты до обработки: {chunk[:100]}")
except websockets.ConnectionClosed as e:
logger.warning(f"WebSocket соединение разорвано: {e}")
self._notify_disconnected()
return
result_message = parse_message(chunk)
# Служебные SignalR-сообщения (ping/close) не относятся к подпискам: если класть их
# в очередь, они разбавляют батчи, которые сценарии вычитывают при накоплении
if self._is_service_signalr_message(result_message):
logger.debug(f"Пропущено служебное сообщение SignalR: {result_message}")
continue
self.messages_received += 1
await self.recv_queue.put(result_message)
if not self.suppress_recv_logging:
str_message = str(result_message)
logger.info(
f"Обработанное сообщение от api-gateway: {str_message[:200]}... полное сообщение в attach",
)
continue
def _negotiate(self) -> str:
"""
Выполняет negotiate и возвращает connectionToken для параметра id=.
Вызов синхронный и выполняется через asyncio.to_thread, чтобы не блокировать
event loop: на постоянном соединении в этом же loop живут приём сообщений и пинги.
"""
result = self._negotiator.negotiate(self._x_user_id)
self._connection_id = result.connection_id
self._connection_token = result.connection_token
return result.connection_token
def _build_ws_request_url(self, connection_token: str) -> str:
"""
Собирает wss URL как у фронта: token, xUserId и id=connectionToken.
Параметр id= связывает wss-соединение с записью negotiate, по которой api-gateway
держит консьюмеры core и layer builder для подписанного клиента.
"""
return (
f"{self._ws_url}/?token={self._access_token}&xUserId={self._x_user_id}"
f"&{WS_Const.WS_ID_QUERY_PARAM}={connection_token}"
)
async def _ping_loop(self) -> None:
"""
Отправляет SignalR Ping ([6]) раз в SIGNALR_PING_INTERVAL_S.
Сервер SignalR ждёт от клиента любое сообщение в пределах ClientTimeoutInterval.
Protocol-level ping вебсокета (ping_interval) для этого не засчитывается, поэтому без [6]
сервер считает клиента мёртвым, рвёт соединение и перестаёт вычитывать сообщения подписок.
"""
assert self._ws is not None
packet = encode_with_varint_prefix(msgpack.packb([WS_Const.PING_MESSAGE_TYPE], use_bin_type=True))
while not self._stop_event.is_set():
try:
await asyncio.sleep(WS_Const.SIGNALR_PING_INTERVAL_S)
if self._stop_event.is_set() or not self._ws:
return
await self._ws.send(packet)
self.ws_pings_sent += 1
logger.debug("Отправлен SignalR ping [6]")
except (websockets.WebSocketException, OSError) as error:
# Прогон не роняем: фиксируем ошибку и сообщаем владельцу, он поднимет реконнект
self.ws_pings_failed += 1
logger.warning(f"Не удалось отправить SignalR ping [6]: {error}")
self._notify_disconnected()
return
@staticmethod
def _is_service_signalr_message(message: Any) -> bool:
"""True для служебных сообщений SignalR (Ping/Close) - в очередь подписок они не попадают."""
if not isinstance(message, list) or not message:
return False
message_type = message[WS_Const.SIGNALR_MESSAGE_TYPE_INDEX]
return message_type in (WS_Const.PING_MESSAGE_TYPE, WS_Const.CLOSE_MESSAGE_TYPE)
def _notify_disconnected(self) -> None:
"""Уведомляет о разрыве; ошибка обработчика не должна ронять прогон."""
if self._on_disconnected is None:
return
try:
self._on_disconnected()
except Exception as error:
logger.warning(f"Обработчик разрыва соединения завершился с ошибкой: {error}")
def _log_connection_summary(self) -> None:
"""
Выводит итоги работы соединения: пинги, принятые сообщения, размер очереди.
Смысл мониторинга: отправленные пинги вместе с растущим числом принятых сообщений
означают, что клиент подписан и соединение активно, то есть api-gateway вычитывает
сообщения подписок, а не копит их.
"""
logger.info(
"[WS_HEALTH] connectionId=%s pings_sent=%s pings_failed=%s messages_received=%s "
"queue_size=%s reconnects=%s",
self._connection_id,
self.ws_pings_sent,
self.ws_pings_failed,
self.messages_received,
self.recv_queue.qsize(),
self.reconnect_count,
)
@staticmethod
async def _cancel_task(task: Optional[asyncio.Task]) -> None:
"""Отменяет фоновую задачу и дожидается завершения, не пробрасывая CancelledError."""
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def invoke(self, target: str, args: list) -> None:
"""
Отправляет удаленный вызов invocation о websocket соединению
Метод формирует сообщение по протоколу SignalR, включая:
- типа сообщения
- заголовки
- уникальный идентификатор запроса
- имя целевого метода
- аргументы запроса
Сообщение запаковывается в messagepack и отправляется через текущее websocket соединение
"""
if not self._ws:
raise websockets.WebSocketException("Не установлено подключение по wss")
self._invocation_id = str(self._next_id)
self._next_id += 1
invocation = [
WS_Const.DEFAULT_SIGNALR_MESSAGE_TYPE,
WS_Const.DEFAULT_SIGNALR_MAP_HEADERS,
self._invocation_id,
target,
[args],
]
logger.info(f"Сообщение подготовлено к отправке: {invocation}")
payload = msgpack.packb(invocation, use_bin_type=True)
packet = encode_with_varint_prefix(payload)
logger.debug(f"Отправляем сообщение: {packet}")
await self._ws.send(packet)
async def invoke_stream(self, target: str, args: list) -> None:
"""
Отправляет streaming-вызов (StreamInvocation) по протоколу SignalR.
"""
if not self._ws:
raise websockets.WebSocketException("Не установлено подключение по wss")
self._invocation_id = str(self._next_id)
self._next_id += 1
invocation = [
WS_Const.STREAM_INVOCATION_MESSAGE_TYPE,
WS_Const.DEFAULT_SIGNALR_MAP_HEADERS,
self._invocation_id,
target,
[args],
]
logger.info(f"Streaming-сообщение подготовлено к отправке: {invocation}")
payload = msgpack.packb(invocation, use_bin_type=True)
packet = encode_with_varint_prefix(payload)
await self._ws.send(packet)
async def receive_by_type(self, message_type: str, timeout: float = WS_Const.FILTERING_TIMEOUT) -> List[Any]:
"""
Фильтрует сообщения по message_type
"""
try:
return await self._receive_by(filter_func=lambda msg: is_desired_type(msg, message_type), timeout=timeout)
except websockets.WebSocketException:
raise websockets.WebSocketException(f"Ошибка при фильтрации сообщений по {message_type}")
async def receive_by_invocation_id(
self, invocation_id: str, timeout: float = WS_Const.FILTERING_TIMEOUT
) -> List[Any]:
"""
Фильтрует сообщения по invocation_id
"""
try:
return await self._receive_by(
filter_func=lambda msg: is_desired_invocation_id(msg, invocation_id), timeout=timeout
)
except websockets.WebSocketException:
raise websockets.WebSocketException("Ошибка при фильтрации сообщений по invocation_id")
async def _receive_by(self, filter_func: Callable[[list], bool], timeout: float) -> List[Any]:
"""
Ждет и фильтрует сообщение по filter_func
"""
# 1) Единая точка вычисления дедлайна
deadline = time.monotonic() + timeout
while True:
# 2) Остаток времени до таймаута
remaining = deadline - time.monotonic()
if remaining <= 0:
raise asyncio.TimeoutError(f"Timeout при фильтрации сообщений {timeout:.1f} секунд")
try:
# 3) Получает сообщение
msg = await asyncio.wait_for(self.recv_queue.get(), timeout=remaining)
except asyncio.TimeoutError:
# 4) Явно перехватывает и пробрасывает свой TimeoutError
raise asyncio.TimeoutError(f"Timeout при фильтрации сообщений {timeout:.1f} секунд")
# 5) Фильтрация по filter_func
if isinstance(msg, list) and filter_func(msg):
return msg
async def reconnect(self):
"""
Переподключение websocket соединения
"""
try:
await self._cancel_task(self._ping_task)
self._ping_task = None
await self._cancel_task(self._recv_task)
self._recv_task = None
if self._ws:
await self._ws.close()
self._ws = None
await self._connect_loop(WS_Const.WS_RECONNECT_TIMEOUT_SECONDS)
self.reconnect_count += 1
return self
except (asyncio.TimeoutError, ConnectionError, ConnectionResetError, OSError) as error:
raise RuntimeError(f"Ошибка при попытке переподключения {error}")