Andrey Epifancev bf2f60b3b5 fix: NATS — отказ в правах логируется отдельно; drain ограничен 10 с (I-3, M-7)
nats-py 2.14 при Permissions Violation не закрывает соединение и не роняет
subscribe/flush/publish, а только вызывает error_cb. Раньше это логировалось
как «Нет связи с NATS, повторяем подключение» с лимитом раз в минуту. Теперь
_on_error распознаёт «permissions violation» и пишет error
error_category=nats_permission с subject, каждый раз и без серии ошибок
подключения. Исправлены ложные утверждения docstring («ошибки прав
проявляются сразу»).

connect получает drain_timeout=DRAIN_TIMEOUT (10 с вместо 30 с nats-py):
STOP_TIMEOUT воркера + drain укладываются в 30 с платформы.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:46:33 +04:00

382 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Соединение сервиса с NATS: core NATS без JetStream (design D-2, D-5, D-6; spec «Соединение с NATS по правилам
шаблона, без JetStream», «Подтверждение запроса до обработки», «Результат по контракту»). Сторона сервиса
распознавания; образец — `arm-core/api/src/core/nats.py` (сторона ядра).
- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных; задан
только один — `NatsConfigError` (подключение не выполняется, ошибка в логе).
- Первое подключение выполняет фоновый supervisor (`start()`): старт приложения не ждёт NATS. nats-py 2.14 с
`max_reconnect_attempts=-1` сам повторяет и первое подключение (параметра `retry_on_failed_connect` в 2.14 нет:
`connect` просто не возвращается, пока сервер недоступен), поэтому ждать его в lifespan нельзя. Если `connect` всё же
завершился исключением, supervisor повторяет попытку с паузой 1 → 2 → 4 → … ≤ 30 с. После подключения один раз
вызывается `on_connected(conn)` (подписки воркера); если он упал, созданные в этой попытке подписки снимаются и
вызов повторяется с той же паузой. Подписки после разрыва nats-py восстанавливает сам.
- `subscribe_requests(cb)` — подписка на `recognition.tabular.request` в queue group `tabular-parser`: из нескольких
инстанций сообщение получает одна. После `subscribe` — `flush`: сервер обработал SUB, ошибки соединения проявляются
здесь. Отказ в правах (`Permissions Violation`) nats-py 2.14 так не проявляет: соединение остаётся открытым,
`subscribe`/`flush`/`publish` проходят, сервер лишь присылает `-ERR` в `error_cb` — он логируется отдельно уровнем
error (`error_category=nats_permission`, subject). Исключения обработчика логируются, подписка живёт.
- `publish(subject, payload, msg_id)` — заголовок `Nats-Msg-Id` и `flush` (подтверждение сервером); первая попытка и
до `publish_retries` повторов с тем же `msg_id` и паузой `publish_retry_pause` между попытками (D-5, R-16: 3 повтора,
2 с — всего до 4 попыток), затем `NatsPublishError` — воркер логирует ошибку, задание дойдёт до `timeout` в ядре.
Нет соединения — попытка неудачна сразу (без ожидания), пауза и повтор как обычно.
- Ошибки подключения логируются без шума: первая ошибка серии — warning, дальше не чаще раза в минуту.
- Остановка: `drain` ограничен `DRAIN_TIMEOUT` (у nats-py по умолчанию 30 с), чтобы вместе со `STOP_TIMEOUT` воркера
уложиться в 30 с, которые платформа даёт на завершение.
"""
import asyncio
import base64
import re
import time
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any, Protocol, runtime_checkable
import nkeys
from loguru import logger
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from src.contract.subjects import NATS_MSG_ID_HEADER, TABULAR_REQUEST_SUBJECT
from src.core.config import NATSSettings
REQUEST_QUEUE_GROUP = "tabular-parser"
CLIENT_NAME = "tabular-parser"
MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно
RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py
CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения
FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG)
DRAIN_TIMEOUT = 10 # секунд на drain при остановке (nats-py: 30); STOP_TIMEOUT воркера + drain ≤ 30 с платформы
PUBLISH_RETRIES = 3 # повторов публикации после первой попытки (settings.PUBLISH_RETRIES, R-16)
PUBLISH_RETRY_PAUSE = 2.0 # секунд между попытками публикации (D-5)
BACKOFF_INITIAL = 1.0
BACKOFF_MAX = 30.0
ERROR_LOG_INTERVAL = 60.0 # не чаще раза в минуту повторяем warning об ошибках одной серии
PERMISSIONS_VIOLATION = "permissions violation" # текст `-ERR` сервера при отказе в правах (nats-py: в нижнем регистре)
_SUBJECT_IN_ERROR_RE = re.compile(r'(?:to|for)\s+"([^"]+)"')
MessageHandler = Callable[[Msg], Awaitable[None]]
SleepFn = Callable[[float], Awaitable[None]]
class NatsError(Exception):
"""Базовая ошибка слоя NATS сервиса."""
class NatsConfigError(NatsError, RuntimeError):
"""Ошибка конфигурации учётных данных NATS: повтор подключения бесполезен."""
class NatsNotConnectedError(NatsError):
"""Операция требует установленного соединения с NATS."""
class NatsPublishError(NatsError):
"""Публикация не подтверждена ни первой попыткой, ни повторами."""
@runtime_checkable
class Publisher(Protocol):
"""Публикация в NATS, нужная воркеру (design D-7): исключение `NatsPublishError` — все попытки исчерпаны."""
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: ...
def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
"""Параметры аутентификации для `Client.connect`.
Пустые `NATS_SEED` и `NATS_JWT` — без учётных данных (docker compose, тесты): `{}`. Заданы оба — колбэки
nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`.
"""
seed = settings.NATS_SEED
jwt = settings.NATS_JWT
if not seed and not jwt:
return {}
if not seed or not jwt:
raise NatsConfigError(
"Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе"
)
try:
keypair = nkeys.from_seed(seed.encode("utf-8"))
except Exception as exc:
raise NatsConfigError(f"Некорректный NATS_SEED: {type(exc).__name__}") from None
def user_jwt_cb() -> bytes:
return jwt.encode("utf-8")
def signature_cb(nonce: str | bytes) -> bytes:
if isinstance(nonce, str):
nonce = nonce.encode("utf-8")
return base64.b64encode(keypair.sign(nonce))
return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb}
class NatsConnection:
"""Соединение сервиса с NATS: фоновое подключение, подписка на запросы, публикация с повторами.
Реализует `Publisher`. `client_factory` и `sleep` подменяются в тестах; `sleep` используется и для пауз
переподключения supervisor'а, и для пауз между попытками публикации.
"""
def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги
self,
settings: NATSSettings,
*,
on_connected: Callable[["NatsConnection"], Awaitable[None]] | None = None,
publish_retries: int = PUBLISH_RETRIES,
publish_retry_pause: float = PUBLISH_RETRY_PAUSE,
client_factory: Callable[[], Any] = NATS,
sleep: SleepFn = asyncio.sleep,
backoff_initial: float = BACKOFF_INITIAL,
backoff_max: float = BACKOFF_MAX,
flush_timeout: int = FLUSH_TIMEOUT,
) -> None:
if publish_retries < 0:
raise ValueError("publish_retries должен быть ≥ 0")
self._settings = settings
self._on_connected = on_connected
self._publish_retries = publish_retries
self._publish_retry_pause = publish_retry_pause
self._client_factory = client_factory
self._sleep = sleep
self._backoff_initial = backoff_initial
self._backoff_max = backoff_max
self._flush_timeout = flush_timeout
self._client: Any = None
self._supervisor: asyncio.Task[None] | None = None
self._ready = asyncio.Event()
self._stopping = False
self._error_streak = 0
self._last_error_log = 0.0
self._subscriptions: list[Any] = [] # подписки subscribe_requests (снимаются при сбое on_connected)
# --- состояние ------------------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""Фактическое состояние соединения (False до первого подключения, во время переподключения и после stop)."""
return self._client is not None and bool(self._client.is_connected)
async def wait_ready(self, timeout: float) -> bool:
"""Ждёт подключения и выполнения `on_connected`; `False`, если не дождались за `timeout` секунд."""
try:
await asyncio.wait_for(self._ready.wait(), timeout)
except TimeoutError:
return False
return True
# --- жизненный цикл -------------------------------------------------------------------------
async def start(self) -> None:
"""Запускает фоновый supervisor подключения; NATS не ждёт."""
if self._supervisor is None:
self._supervisor = asyncio.create_task(self._supervise(), name="nats-supervisor")
async def stop(self) -> None:
"""Останавливает supervisor и закрывает соединение; исключения наружу не выходят."""
self._stopping = True
supervisor = self._supervisor
if supervisor is not None:
supervisor.cancel()
try:
await supervisor
except asyncio.CancelledError:
# Подавляем только отмену самого supervisor'а; отмену вызывающей задачи пробрасываем
current = asyncio.current_task()
if current is not None and current.cancelling():
raise
except Exception:
logger.exception("Supervisor NATS завершился с ошибкой. error_category=nats_error")
self._supervisor = None
client = self._client
if client is None or client.is_closed:
return
try:
if client.is_connected:
await client.drain() # дообработать полученное
else:
await client.close()
except Exception as exc:
logger.warning(
"Ошибка при закрытии соединения NATS. error_category=nats_error, error_type={error_type}, "
"error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
with suppress(Exception):
await client.close()
async def _connect(self) -> None:
"""Одна попытка подключения новым клиентом (nats-py с `max_reconnect_attempts=-1` повторяет её сам)."""
auth = build_nats_auth(self._settings)
client = self._client_factory()
self._client = client
try:
await client.connect(
self._settings.NATS_URL,
name=CLIENT_NAME,
error_cb=self._on_error,
disconnected_cb=self._on_disconnected,
reconnected_cb=self._on_reconnected,
closed_cb=self._on_closed,
max_reconnect_attempts=MAX_RECONNECT_ATTEMPTS,
reconnect_time_wait=RECONNECT_TIME_WAIT,
connect_timeout=CONNECT_TIMEOUT,
drain_timeout=DRAIN_TIMEOUT,
**auth,
)
except BaseException:
with suppress(Exception):
await client.close()
raise
async def _supervise(self) -> None:
delay = self._backoff_initial
while True:
try:
await self._connect()
break
except NatsConfigError as exc:
logger.error(
"Конфигурация NATS некорректна, подключение не выполняется. error_category=config_error, "
"error={error}",
error=str(exc),
)
return
except Exception as exc:
self._report_error(exc)
await self._sleep(delay)
delay = min(delay * 2, self._backoff_max)
self._reset_error_streak()
logger.info("Подключение к NATS установлено. url={url}", url=self._settings.NATS_URL)
delay = self._backoff_initial
while self._on_connected is not None:
created_before = len(self._subscriptions)
try:
await self._on_connected(self)
break
except Exception as exc:
logger.error(
"Не удалось выполнить подписки NATS. error_category=nats_error, error_type={error_type}, "
"error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
await self._drop_subscriptions(created_before)
await self._sleep(delay)
delay = min(delay * 2, self._backoff_max)
self._ready.set()
async def _drop_subscriptions(self, keep: int) -> None:
"""Снимает подписки сверх первых `keep` (неудачная попытка `on_connected`): повтор не дублирует их."""
stale, self._subscriptions = self._subscriptions[keep:], self._subscriptions[:keep]
for subscription in stale:
with suppress(Exception):
await subscription.unsubscribe()
# --- подписка и публикация ------------------------------------------------------------------
async def subscribe_requests(
self, cb: MessageHandler, *, queue: str = REQUEST_QUEUE_GROUP, subject: str = TABULAR_REQUEST_SUBJECT
) -> Any:
"""Подписка на запросы (`recognition.tabular.request`) в queue group; исключения `cb` логируются."""
if not self.is_connected:
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна")
async def safe_cb(msg: Msg) -> None:
try:
await cb(msg)
except Exception as exc:
logger.exception(
"Ошибка обработки сообщения NATS. error_category=handler_error, subject={subject}, "
"error_type={error_type}, error={error}",
subject=msg.subject,
error_type=type(exc).__name__,
error=repr(exc),
)
subscription = await self._client.subscribe(subject, queue=queue, cb=safe_cb)
self._subscriptions.append(subscription)
# Сервер обработал SUB: ошибки соединения проявляются здесь; отказ в правах приходит только в error_cb
await self._client.flush(timeout=self._flush_timeout)
logger.info("Подписка NATS активна. subject={subject}, queue={queue}", subject=subject, queue=queue)
return subscription
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None:
"""`Nats-Msg-Id: <msg_id>`; попытка + до `publish_retries` повторов с паузой, затем `NatsPublishError`."""
attempts = self._publish_retries + 1
last_error: str = ""
for attempt in range(1, attempts + 1):
if attempt > 1:
await self._sleep(self._publish_retry_pause)
try:
await self._publish_once(subject, payload, msg_id)
return
except NatsPublishError as exc:
last_error = str(exc)
logger.warning(
"Публикация в NATS не удалась. error_category=nats_error, subject={subject}, msg_id={msg_id}, "
"attempt={attempt}, attempts={attempts}, error={error}",
subject=subject,
msg_id=msg_id,
attempt=attempt,
attempts=attempts,
error=last_error,
)
raise NatsPublishError(f"Публикация {msg_id} в {subject} не удалась за {attempts} попыток: {last_error}")
async def _publish_once(self, subject: str, payload: bytes, msg_id: str) -> None:
if not self.is_connected:
raise NatsPublishError("Нет соединения с NATS")
try:
await self._client.publish(subject, payload, headers={NATS_MSG_ID_HEADER: msg_id})
await self._client.flush(timeout=self._flush_timeout)
except Exception as exc:
raise NatsPublishError(f"публикация не подтверждена ({type(exc).__name__})") from exc
# --- колбэки nats-py и логирование без шума -------------------------------------------------
def _report_error(self, exc: BaseException) -> None:
now = time.monotonic()
self._error_streak += 1
if self._error_streak == 1 or now - self._last_error_log >= ERROR_LOG_INTERVAL:
logger.warning(
"Нет связи с NATS, повторяем подключение. error_category=nats_error, url={url}, "
"error_type={error_type}, error={error}, attempts={attempts}",
url=self._settings.NATS_URL,
error_type=type(exc).__name__,
error=repr(exc),
attempts=self._error_streak,
)
self._last_error_log = now
def _reset_error_streak(self) -> None:
self._error_streak = 0
self._last_error_log = 0.0
async def _on_error(self, exc: Exception) -> None:
text = str(exc)
if PERMISSIONS_VIOLATION in text.lower():
# Соединение живо, повтор подключения не поможет: нужны права на subject (каждый отказ — в лог)
match = _SUBJECT_IN_ERROR_RE.search(text)
logger.error(
"NATS отказал в правах: подписка или публикация не работает. error_category=nats_permission, "
"subject={subject}, error={error}",
subject=match.group(1) if match else "-",
error=text,
)
return
self._report_error(exc)
async def _on_disconnected(self) -> None:
if not self._stopping:
logger.warning("Соединение с NATS потеряно, nats-py переподключается")
async def _on_reconnected(self) -> None:
self._reset_error_streak()
logger.info("Соединение с NATS восстановлено")
async def _on_closed(self) -> None:
if self._stopping:
logger.info("Соединение с NATS закрыто")
else:
logger.error("Соединение с NATS закрыто без остановки сервиса. error_category=nats_error")