feat: src/core/nats.py — фоновое подключение к core NATS, publish с Nats-Msg-Id, подписка recognition.result в queue group arm-core

- NatsConnection: supervisor первого подключения в фоне (старт приложения не ждёт NATS), nats-py
  переподключается сам (max_reconnect_attempts=-1, reconnect_time_wait=2) и восстанавливает подписки;
  экспоненциальный backoff 1→30 с, если connect завершился исключением; логирование ошибок без шума
  (первая ошибка серии — warning, дальше не чаще раза в минуту).
- publish(subject, payload, msg_id): заголовок Nats-Msg-Id + flush; нет соединения или нет подтверждения —
  NatsPublishError. subscribe_results(handler): queue group arm-core, исключения обработчика логируются.
- build_nats_auth перенесён из app.py; ошибка учётных данных — NatsConfigError, категория config_error,
  без повторов (T4.1). Первое подключение повторяется (T4.2): прежний await connect в lifespan при
  недоступном NATS висел бесконечно (nats-py с max_reconnect_attempts=-1 повторяет первое подключение сам).
- app.py: подключение переехало в core/nats.py (R-3), lifespan запускает/останавливает NatsConnection,
  подписка — заглушка services/recognition_exchange.handle_message (полная обработка — задача 24).
- Тесты: юнит (поддельный клиент, инжектируемый sleep, отсутствие JetStream в src/) и интеграционные
  против NATS без JetStream (заголовок, queue group без дублей, недоступный сервер).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 23:47:39 +04:00
parent dcbeea5756
commit 4e5e1a7b5b
7 changed files with 968 additions and 115 deletions

View File

@ -165,8 +165,10 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak
слушает `http`. S3 в Срезе 0 не используется (`app.state.storage` остаётся пустым). Это до исправления в задаче 22
(отдельная настройка схемы подключения к MinIO).
Недоступность SMB, S3 или NATS при старте приложение не роняет: ошибка логируется, состояние NATS видно в
`/healthcheck`.
Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS
видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning
в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает
подписку.
Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом
запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность
@ -292,7 +294,8 @@ api/
app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA
api/endpoints/ эндпоинты (me.py, dev_auth.py); router.py собирает /api/v1
api/dependencies/ зависимости FastAPI (current_user, проверка прав)
core/ config, auth (JWT), clients (S3), nats_callback, logger, exception_handlers
core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), nats_callback,
logger, exception_handlers
db/ движок и сессия (схема dkb)
models/ ORM-модели, 1:1 с DBML
repositories/ доступ к данным

View File

@ -1,21 +1,18 @@
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые NATS_SEED/NATS_JWT — без учётных данных;
подключение при NATS_ENABLED=true независимо от DEBUG), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy
headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA.
Недоступность S3 или NATS при старте не роняет приложение: ошибка логируется, healthcheck показывает состояние.
Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result
(docs/context/03-architecture.md), зависимость require(permission).
Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: core
NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue
group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False),
healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA.
Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает
состояние. Outbox-паблишер и вахтёр сроков — задача 23 (docs/context/03-architecture.md).
"""
import base64
import os
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from http import HTTPStatus
from typing import Any
import nkeys
import smbclient
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, StarletteHTTPException
@ -23,14 +20,15 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from loguru import logger
from miniopy_async import Minio
from nats.aio.client import Client as NATS
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import API_PREFIX, router
from src.core.clients import S3Storage
from src.core.config import NATSSettings, get_settings
from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers
from src.core.nats import NatsConnection
from src.services.recognition_exchange import handle_message
settings = get_settings()
@ -38,34 +36,6 @@ VERSION = settings.VERSION
UP_TIME = datetime.now(UTC)
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
def _build_nats_auth(nats_settings: NATSSettings) -> dict[str, Any]:
"""Параметры аутентификации для ``NATS.connect``.
Пустые NATS_SEED и NATS_JWT — подключение без учётных данных (docker compose, тесты): ``{}``. Заданы оба —
колбэки nkeys/JWT, как в шаблоне. Задан только один — ошибка конфигурации. Подключение переедет в core/nats.py
(задача 22).
"""
seed = nats_settings.NATS_SEED
jwt = nats_settings.NATS_JWT
if not seed and not jwt:
return {}
if not seed or not jwt:
raise RuntimeError("Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе")
keypair = nkeys.from_seed(seed.encode("utf-8"))
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}
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
protected_middleware_available = False
if not settings.DEBUG:
@ -118,33 +88,21 @@ async def _connect_s3() -> S3Storage | None:
return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
async def _connect_nats() -> NATS | None:
"""Подключение к NATS при NATS_ENABLED=true; при ошибке — ``None`` (healthcheck покажет ``down``)."""
async def _subscribe_results(nats: NatsConnection) -> None:
"""Подписки после первого подключения к NATS: `recognition.result` в queue group `arm-core`."""
await nats.subscribe_results(handle_message)
def _create_nats() -> NatsConnection | None:
"""Соединение NATS при NATS_ENABLED=true; при false — ``None`` (healthcheck покажет ``off``)."""
if not settings.NATS_ENABLED:
logger.info("NATS выключен (NATS_ENABLED=false)")
return None
nc = NATS()
try:
await nc.connect(
settings.OPENBAO.NATS.NATS_URL,
max_reconnect_attempts=-1,
reconnect_time_wait=2,
**_build_nats_auth(settings.OPENBAO.NATS),
)
except Exception as exc:
logger.exception(
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return None
logger.info("Подключение к NATS произошло")
return nc
return NatsConnection(settings.OPENBAO.NATS, on_connected=_subscribe_results)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.nats_subs = []
app.state.storage = None
app.state.nats = None
@ -157,18 +115,17 @@ async def lifespan(app: FastAPI):
try:
app.state.storage = await _connect_s3()
app.state.nats = await _connect_nats()
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
app.state.nats = _create_nats()
if app.state.nats is not None:
await app.state.nats.start() # подключение в фоне: старт приложения не ждёт NATS
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
smbclient.reset_connection_cache()
for sub in app.state.nats_subs:
await sub.unsubscribe()
if app.state.nats:
await app.state.nats.close()
if app.state.nats is not None:
await app.state.nats.stop()
if app.state.storage:
await app.state.storage.client.close_session()

310
api/src/core/nats.py Normal file
View File

@ -0,0 +1,310 @@
"""Соединение с NATS: core NATS без JetStream (design D-6; spec recognition-job-exchange, «Соединение с NATS по
шаблону, без JetStream»; docs/context/03-architecture.md, «Контракт обмена через NATS»).
- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных.
- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush`: ошибка соединения проявляется
сразу исключением `NatsPublishError` (outbox оставляет задание в `queued`).
- `subscribe_results(handler)` — подписка на `recognition.result` в queue group `arm-core`: из нескольких инстансов ядра
сообщение получает ровно один. Исключения обработчика логируются, подписка не падает.
- Stream'ов и consumer'ов JetStream нет. Переподключение после разрыва — встроенное в nats-py
(`max_reconnect_attempts=-1`, `reconnect_time_wait=2`), подписки nats-py восстанавливает сам.
- Первое подключение выполняет фоновый supervisor (`start()`): старт приложения не ждёт NATS. nats-py 2.14 с
`max_reconnect_attempts=-1` сам повторяет и первое подключение (раз в `reconnect_time_wait`), поэтому `connect`
возвращается только при успехе; если он всё же завершился исключением, supervisor повторяет попытку с
экспоненциальной паузой 1 → 2 → 4 → … ≤ 30 с. Ошибки подключения логируются без шума: первая ошибка серии — warning
с причиной, дальше не чаще раза в минуту; серия сбрасывается при (пере)подключении.
"""
import asyncio
import base64
import time
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any
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, RESULT_SUBJECT
from src.core.config import NATSSettings
RESULT_QUEUE_GROUP = "arm-core"
CLIENT_NAME = "arm-core"
MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно
RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py
CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения
FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG)
BACKOFF_INITIAL = 1.0
BACKOFF_MAX = 30.0
ERROR_LOG_INTERVAL = 60.0 # не чаще раза в минуту повторяем warning об ошибках одной серии
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):
"""Публикация не подтверждена: соединения нет или сервер не ответил на flush."""
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: фоновое первое подключение, публикация с `Nats-Msg-Id`, подписка на результаты.
`on_connected(conn)` вызывается один раз после первого успешного подключения (подписки); при исключении
повторяется с той же экспоненциальной паузой. `result_subject` и `queue` параметризуются для тестов —
по умолчанию `recognition.result` и `arm-core`.
"""
def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги
self,
settings: NATSSettings,
*,
on_connected: Callable[["NatsConnection"], Awaitable[None]] | None = None,
result_subject: str = RESULT_SUBJECT,
queue: str = RESULT_QUEUE_GROUP,
client_factory: Callable[[], Any] = NATS,
sleep: SleepFn = asyncio.sleep,
backoff_initial: float = BACKOFF_INITIAL,
backoff_max: float = BACKOFF_MAX,
error_log_interval: float = ERROR_LOG_INTERVAL,
flush_timeout: int = FLUSH_TIMEOUT,
) -> None:
self._settings = settings
self._on_connected = on_connected
self.result_subject = result_subject
self.queue = queue
self._client_factory = client_factory
self._sleep = sleep
self._backoff_initial = backoff_initial
self._backoff_max = backoff_max
self._error_log_interval = error_log_interval
self._flush_timeout = flush_timeout
self._client: Any = None
self._supervisor: asyncio.Task[None] | None = None
self._ready = asyncio.Event()
self._stopping = False
# Серия ошибок подключения: сколько подавлено и когда последний раз писали warning
self._error_streak = 0
self._last_error_log = 0.0
# --- состояние ------------------------------------------------------------------------------
@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
if self._supervisor is not None:
self._supervisor.cancel()
with suppress(asyncio.CancelledError, Exception):
await self._supervisor
self._supervisor = None
await self.close()
async def close(self) -> None:
"""Подключённый клиент — `drain` (дообработать полученное), иначе `close`; без исключений наружу."""
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,
**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:
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._sleep(delay)
delay = min(delay * 2, self._backoff_max)
self._ready.set()
# --- публикация и подписка ------------------------------------------------------------------
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None:
"""Публикует сообщение с заголовком `Nats-Msg-Id: <msg_id>` и ждёт подтверждения сервера (`flush`)."""
if not self.is_connected:
raise NatsPublishError(f"Нет соединения с NATS: сообщение {msg_id} в {subject} не опубликовано")
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"Публикация в NATS не подтверждена: {subject}, {msg_id} ({type(exc).__name__})"
) from exc
async def subscribe_results(self, handler: MessageHandler) -> Any:
"""Подписка на `result_subject` в queue group `queue`; исключения `handler` логируются, подписка живёт."""
if not self.is_connected:
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {self.result_subject} невозможна")
async def safe_handler(msg: Msg) -> None:
try:
await handler(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(self.result_subject, queue=self.queue, cb=safe_handler)
logger.info(
"Подписка NATS активна. subject={subject}, queue={queue}", subject=self.result_subject, queue=self.queue
)
return subscription
# --- колбэки 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 >= self._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:
self._report_error(exc)
async def _on_disconnected(self) -> None:
if self._stopping:
return
logger.warning("Соединение с NATS потеряно, nats-py переподключается")
async def _on_reconnected(self) -> None:
self._reset_error_streak()
logger.info("Соединение с NATS восстановлено")
async def _on_closed(self) -> None:
logger.info("Соединение с NATS закрыто")

View File

@ -0,0 +1,14 @@
"""Обмен с сервисами распознавания через `recognition_job` (design D-6; docs/context/03-architecture.md).
Пока здесь только заглушка обработчика `recognition.result`: подписка в `src/core/nats.py` уже вызывает её.
Полноценная обработка (ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23);
`publish_pending()` и `expire_deadlines()` — задача 23.
"""
from loguru import logger
from nats.aio.msg import Msg
async def handle_message(msg: Msg) -> None:
"""Заглушка: логирует subject и размер полученного сообщения, ничего не меняет."""
logger.info("Получено сообщение NATS. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data))

View File

@ -1,53 +1,24 @@
"""Быстрые тесты каркаса приложения: аутентификация NATS, /healthcheck, префикс API (без БД и NATS)."""
"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API (без БД и NATS).
Аутентификация и supervisor NATS — tests/test_nats_core.py (подключение переехало в src/core/nats.py, рулинг R-3).
"""
import asyncio
import importlib
import os
from http import HTTPStatus
from types import SimpleNamespace
from typing import Any
import httpx
import nkeys
import pytest
from src.api.router import router
from src.core.config import NATSSettings
from src.core.nats import NatsConnection
# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib
app_module = importlib.import_module("src.app")
def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings:
return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt)
def _valid_seed() -> str:
return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8")
def test_build_nats_auth_empty_credentials_gives_no_kwargs():
assert app_module._build_nats_auth(_nats_settings()) == {}
@pytest.mark.parametrize(
("seed", "jwt"),
[("SU-only-seed", ""), ("", "only-jwt")],
)
def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str):
with pytest.raises(RuntimeError, match="NATS_SEED"):
app_module._build_nats_auth(_nats_settings(seed, jwt))
def test_build_nats_auth_valid_credentials_give_callbacks():
seed = _valid_seed()
auth = app_module._build_nats_auth(_nats_settings(seed, "header.payload.signature"))
assert set(auth) == {"user_jwt_cb", "signature_cb"}
assert auth["user_jwt_cb"]() == b"header.payload.signature"
signature = auth["signature_cb"](b"nonce")
assert isinstance(signature, bytes)
assert signature
@pytest.fixture
async def client():
# Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить
@ -97,10 +68,15 @@ def test_router_prefix():
class _FakeNats:
def __init__(self) -> None:
self.closed = False
self.started = False
self.stopped = False
self.is_connected = False
async def close(self) -> None:
self.closed = True
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.stopped = True
class _FakeS3Client:
@ -123,7 +99,7 @@ async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest.
return None
monkeypatch.setattr(app_module, "_connect_s3", unavailable)
monkeypatch.setattr(app_module, "_connect_nats", unavailable)
monkeypatch.setattr(app_module, "_create_nats", lambda: None)
reached_yield = False
async with app_module.lifespan(app_module.app):
@ -135,7 +111,7 @@ async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest.
@pytest.mark.usefixtures("_no_smb")
async def test_lifespan_closes_created_resources(monkeypatch: pytest.MonkeyPatch):
async def test_lifespan_starts_and_stops_nats_and_closes_s3(monkeypatch: pytest.MonkeyPatch):
fake_nats = _FakeNats()
s3_client = _FakeS3Client()
storage = SimpleNamespace(client=s3_client)
@ -143,17 +119,62 @@ async def test_lifespan_closes_created_resources(monkeypatch: pytest.MonkeyPatch
async def connect_s3() -> SimpleNamespace:
return storage
async def connect_nats() -> _FakeNats:
return fake_nats
monkeypatch.setattr(app_module, "_connect_s3", connect_s3)
monkeypatch.setattr(app_module, "_connect_nats", connect_nats)
monkeypatch.setattr(app_module, "_create_nats", lambda: fake_nats)
async with app_module.lifespan(app_module.app):
assert app_module.app.state.storage is storage
assert app_module.app.state.nats is fake_nats
assert not fake_nats.closed
assert fake_nats.started
assert not fake_nats.stopped
assert not s3_client.session_closed
assert fake_nats.closed
assert fake_nats.stopped
assert s3_client.session_closed
class _NeverConnectingClient:
"""Клиент nats-py, который никогда не подключается: connect висит (как nats-py без сервера)."""
is_connected = False
is_closed = False
async def connect(self, servers: str, **options: Any) -> None:
await asyncio.Event().wait()
async def close(self) -> None:
self.is_closed = True
@pytest.mark.usefixtures("_no_smb")
async def test_lifespan_does_not_wait_for_nats_and_healthcheck_is_down(monkeypatch: pytest.MonkeyPatch):
"""Регрессия: недоступный NATS не задерживает старт; до первого подключения healthcheck показывает down."""
async def unavailable() -> None:
return None
nats = NatsConnection(app_module.settings.OPENBAO.NATS, client_factory=_NeverConnectingClient)
monkeypatch.setattr(app_module, "_connect_s3", unavailable)
monkeypatch.setattr(app_module, "_create_nats", lambda: nats)
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
async with app_module.lifespan(app_module.app):
transport = httpx.ASGITransport(app=app_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
response = await c.get("/healthcheck")
assert response.json()["nats"] == "down"
assert not nats.is_connected
def test_create_nats_disabled_returns_none(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False)
assert app_module._create_nats() is None
def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
nats = app_module._create_nats()
assert isinstance(nats, NatsConnection)
assert (nats.result_subject, nats.queue) == ("recognition.result", "arm-core")
assert not nats.is_connected # создание не подключает: подключение — в start()

379
api/tests/test_nats_core.py Normal file
View File

@ -0,0 +1,379 @@
"""Быстрые тесты `src/core/nats.py` без сервера NATS: аутентификация, supervisor первого подключения, publish.
Клиент nats-py подменяется фабрикой (`client_factory`), паузы backoff — инжектируемым `sleep`
(spec recognition-job-exchange, «Соединение с NATS по шаблону, без JetStream»; design D-6).
"""
import ast
import asyncio
import os
from collections.abc import Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import nkeys
import pytest
from loguru import logger
from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT
from src.core.config import NATSSettings
from src.core.nats import (
RESULT_QUEUE_GROUP,
NatsConfigError,
NatsConnection,
NatsNotConnectedError,
NatsPublishError,
build_nats_auth,
)
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings:
return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt)
def _valid_seed() -> str:
return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8")
@pytest.fixture
def log_records() -> Iterator[list[Any]]:
"""Записи loguru, выпущенные за время теста."""
records: list[Any] = []
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
yield records
logger.remove(handler_id)
# --- build_nats_auth ----------------------------------------------------------------------------
def test_build_nats_auth_empty_credentials_gives_no_kwargs():
assert build_nats_auth(_nats_settings()) == {}
@pytest.mark.parametrize(("seed", "jwt"), [("SU-only-seed", ""), ("", "only-jwt")])
def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str):
with pytest.raises(NatsConfigError, match="NATS_SEED"):
build_nats_auth(_nats_settings(seed, jwt))
def test_build_nats_auth_invalid_seed_is_config_error():
with pytest.raises(NatsConfigError, match="NATS_SEED"):
build_nats_auth(_nats_settings("not-a-seed", "header.payload.signature"))
def test_nats_config_error_is_runtime_error():
assert issubclass(NatsConfigError, RuntimeError)
def test_build_nats_auth_valid_credentials_give_callbacks():
auth = build_nats_auth(_nats_settings(_valid_seed(), "header.payload.signature"))
assert set(auth) == {"user_jwt_cb", "signature_cb"}
assert auth["user_jwt_cb"]() == b"header.payload.signature"
signature = auth["signature_cb"]("nonce")
assert isinstance(signature, bytes)
assert signature
# --- поддельный клиент nats-py ------------------------------------------------------------------
class _FakeClient:
"""Минимальный двойник `nats.aio.client.Client`: подключение, подписка, публикация, закрытие."""
def __init__(self, world: "_FakeWorld") -> None:
self.world = world
self.is_connected = False
self.is_closed = False
self.connect_options: dict[str, Any] = {}
self.subscriptions: list[dict[str, Any]] = []
self.published: list[dict[str, Any]] = []
self.flush_error: Exception | None = None
self.drained = False
async def connect(self, servers: str, **options: Any) -> None:
self.world.connect_calls += 1
self.connect_options = {"servers": servers, **options}
if self.world.connect_calls <= self.world.fail_times:
raise OSError("Connection refused")
self.is_connected = True
async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> SimpleNamespace:
self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb})
return SimpleNamespace(subject=subject, queue=queue)
async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None:
self.published.append({"subject": subject, "payload": payload, "headers": headers})
async def flush(self, timeout: int = 10) -> None:
if self.flush_error is not None:
raise self.flush_error
async def drain(self) -> None:
self.drained = True
await self.close()
async def close(self) -> None:
self.is_connected = False
self.is_closed = True
class _FakeWorld:
"""Фабрика клиентов: первые `fail_times` подключений падают, затем успех."""
def __init__(self, fail_times: int = 0) -> None:
self.fail_times = fail_times
self.connect_calls = 0
self.clients: list[_FakeClient] = []
def __call__(self) -> _FakeClient:
client = _FakeClient(self)
self.clients.append(client)
return client
class _RecordingSleep:
"""Подмена `asyncio.sleep`: запоминает интервалы backoff и не ждёт."""
def __init__(self) -> None:
self.delays: list[float] = []
async def __call__(self, delay: float) -> None:
self.delays.append(delay)
await asyncio.sleep(0)
async def _handler(msg: Any) -> None:
return None
# --- supervisor первого подключения -------------------------------------------------------------
async def test_supervisor_retries_with_backoff_then_connects_and_subscribes_once(log_records: list[Any]):
world = _FakeWorld(fail_times=7)
sleep = _RecordingSleep()
subscribed: list[NatsConnection] = []
async def on_connected(conn: NatsConnection) -> None:
subscribed.append(conn)
await conn.subscribe_results(_handler)
conn = NatsConnection(_nats_settings(), on_connected=on_connected, client_factory=world, sleep=sleep)
assert not conn.is_connected
await conn.start()
assert await conn.wait_ready(timeout=2)
assert conn.is_connected
assert world.connect_calls == 8 # noqa: PLR2004
assert sleep.delays == [1, 2, 4, 8, 16, 30, 30]
assert subscribed == [conn]
connected_client = world.clients[-1]
assert [(s["subject"], s["queue"]) for s in connected_client.subscriptions] == [
(RESULT_SUBJECT, RESULT_QUEUE_GROUP)
]
options = connected_client.connect_options
assert options["servers"] == "nats://localhost:4222"
assert options["max_reconnect_attempts"] == -1
assert options["reconnect_time_wait"] == 2 # noqa: PLR2004
for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"):
assert callable(options[callback])
# Первая ошибка — warning с причиной, остальные в пределах минуты подавлены
warnings = [r for r in log_records if r["level"].name == "WARNING"]
assert len(warnings) == 1
assert "error_category=nats_error" in warnings[0]["message"]
assert "Connection refused" in warnings[0]["message"]
await conn.stop()
assert connected_client.is_closed
async def test_supervisor_connects_first_time_without_sleep():
world = _FakeWorld()
sleep = _RecordingSleep()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=sleep)
await conn.start()
assert await conn.wait_ready(timeout=2)
assert sleep.delays == []
assert world.connect_calls == 1
await conn.stop()
async def test_start_does_not_block_and_stop_cancels_supervisor():
world = _FakeWorld(fail_times=10**6)
never = asyncio.Event()
async def blocking_sleep(delay: float) -> None:
await never.wait()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=blocking_sleep)
await conn.start()
await asyncio.sleep(0.01)
assert not conn.is_connected
assert world.connect_calls == 1
await conn.stop() # без исключений наружу
assert not conn.is_connected
assert not await conn.wait_ready(timeout=0.01)
async def test_config_error_logged_as_config_error_and_not_retried(log_records: list[Any]):
world = _FakeWorld()
sleep = _RecordingSleep()
conn = NatsConnection(_nats_settings(seed="SU-only-seed"), client_factory=world, sleep=sleep)
await conn.start()
assert not await conn.wait_ready(timeout=0.05)
assert world.connect_calls == 0
assert sleep.delays == []
assert not conn.is_connected
errors = [r for r in log_records if r["level"].name == "ERROR"]
assert len(errors) == 1
assert "error_category=config_error" in errors[0]["message"]
assert "nats_error" not in errors[0]["message"]
await conn.stop()
async def test_on_connected_failure_is_retried(log_records: list[Any]):
world = _FakeWorld()
sleep = _RecordingSleep()
calls = 0
async def flaky_on_connected(conn: NatsConnection) -> None:
nonlocal calls
calls += 1
if calls < 3: # noqa: PLR2004
raise RuntimeError("подписка не удалась")
conn = NatsConnection(_nats_settings(), on_connected=flaky_on_connected, client_factory=world, sleep=sleep)
await conn.start()
assert await conn.wait_ready(timeout=2)
assert calls == 3 # noqa: PLR2004
assert sleep.delays == [1, 2]
await conn.stop()
async def test_stop_without_start_is_noop():
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
await conn.stop()
assert not conn.is_connected
async def test_error_callback_logging_is_throttled_and_reset_on_reconnect(log_records: list[Any]):
world = _FakeWorld()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
await conn.start()
assert await conn.wait_ready(timeout=2)
options = world.clients[-1].connect_options
await options["disconnected_cb"]()
for _ in range(5):
await options["error_cb"](OSError("Connection refused"))
await options["reconnected_cb"]()
await options["error_cb"](OSError("Connection reset"))
warnings = [r["message"] for r in log_records if r["level"].name == "WARNING"]
errors_logged = [m for m in warnings if "error_category=nats_error" in m]
assert len(errors_logged) == 2 # noqa: PLR2004 - первая ошибка серии и первая после переподключения
assert any("Connection reset" in m for m in errors_logged)
await conn.stop()
# --- publish / subscribe ------------------------------------------------------------------------
async def test_publish_without_connection_raises_publish_error():
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
with pytest.raises(NatsPublishError, match="NATS"):
await conn.publish("recognition.tabular.request", b"{}", "job:1")
async def test_publish_sends_msg_id_header_and_flushes():
world = _FakeWorld()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
await conn.start()
assert await conn.wait_ready(timeout=2)
await conn.publish("recognition.tabular.request", b'{"a": 1}', "job:1")
assert world.clients[-1].published == [
{"subject": "recognition.tabular.request", "payload": b'{"a": 1}', "headers": {NATS_MSG_ID_HEADER: "job:1"}}
]
await conn.stop()
async def test_publish_flush_failure_raises_publish_error():
world = _FakeWorld()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
await conn.start()
assert await conn.wait_ready(timeout=2)
world.clients[-1].flush_error = TimeoutError()
with pytest.raises(NatsPublishError):
await conn.publish("recognition.tabular.request", b"{}", "job:1")
await conn.stop()
async def test_subscribe_results_handler_errors_are_logged_not_raised(log_records: list[Any]):
world = _FakeWorld()
conn = NatsConnection(
_nats_settings(), client_factory=world, sleep=_RecordingSleep(), result_subject="test.subject", queue="g"
)
await conn.start()
assert await conn.wait_ready(timeout=2)
async def failing_handler(msg: Any) -> None:
raise ValueError("сломанный обработчик")
await conn.subscribe_results(failing_handler)
subscription = world.clients[-1].subscriptions[0]
assert (subscription["subject"], subscription["queue"]) == ("test.subject", "g")
await subscription["cb"](SimpleNamespace(subject="test.subject", data=b"xyz", headers={}))
errors = [r for r in log_records if r["level"].name == "ERROR"]
assert len(errors) == 1
assert "error_category=handler_error" in errors[0]["message"]
await conn.stop()
async def test_subscribe_results_without_connection_raises():
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
with pytest.raises(NatsNotConnectedError):
await conn.subscribe_results(_handler)
async def test_stop_drains_connected_client():
world = _FakeWorld()
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
await conn.start()
assert await conn.wait_ready(timeout=2)
await conn.stop()
assert world.clients[-1].drained
assert not conn.is_connected
# --- без JetStream -------------------------------------------------------------------------------
def test_src_does_not_use_jetstream():
"""JetStream на платформе нет: в src/ нет вызовов `.jetstream()` и импортов `nats.js`."""
offenders: list[str] = []
for path in SRC_DIR.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr.lower() == "jetstream":
offenders.append(f"{path}:{node.lineno} .{node.attr}")
elif isinstance(node, ast.ImportFrom) and (node.module or "").startswith("nats.js"):
offenders.append(f"{path}:{node.lineno} from {node.module}")
elif isinstance(node, ast.Import):
offenders.extend(
f"{path}:{node.lineno} import {alias.name}"
for alias in node.names
if alias.name.startswith("nats.js")
)
assert offenders == []

View File

@ -0,0 +1,169 @@
"""Интеграционные тесты `src/core/nats.py` против NATS без JetStream из `.env.test` (spec recognition-job-exchange,
«Соединение с NATS по шаблону, без JetStream»; design D-6).
Subject'ы — уникальные на тест (`test.<uuid>`): общий `recognition.result` не трогаем, чтобы не пересекаться
с другими запущенными инстансами ядра.
"""
import asyncio
import time
import uuid
from collections import Counter
from typing import Any
import pytest
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from src.contract.subjects import NATS_MSG_ID_HEADER
from src.core.config import NATSSettings, get_settings
from src.core.nats import RESULT_QUEUE_GROUP, NatsConnection, NatsPublishError
pytestmark = pytest.mark.integration
READY_TIMEOUT = 5.0
START_MAX_SECONDS = 0.5 # start() не ждёт подключения
STOP_MAX_SECONDS = 2.0 # stop() не висит в цикле подключения nats-py
def _nats_settings() -> NATSSettings:
return get_settings().OPENBAO.NATS
def _unique_subject() -> str:
return f"test.{uuid.uuid4().hex}"
async def _wait_until(predicate: Any, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() > deadline:
raise AssertionError("условие не выполнено за отведённое время")
await asyncio.sleep(0.02)
async def _start(conn: NatsConnection) -> NatsConnection:
await conn.start()
if not await conn.wait_ready(timeout=READY_TIMEOUT):
await conn.stop()
pytest.fail(f"NATS недоступен ({_nats_settings().NATS_URL}, .env.test)", pytrace=False)
return conn
async def test_publish_delivers_payload_and_msg_id_header():
subject = _unique_subject()
received: list[Msg] = []
async def handler(msg: Msg) -> None:
received.append(msg)
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject))
try:
assert conn.is_connected
await conn.publish(subject, b'{"job": 1}', "job-1:1")
await _wait_until(lambda: len(received) == 1)
finally:
await conn.stop()
msg = received[0]
assert msg.subject == subject
assert msg.data == b'{"job": 1}'
assert msg.headers == {NATS_MSG_ID_HEADER: "job-1:1"}
async def test_queue_group_delivers_each_message_to_exactly_one_instance():
"""Два инстанса ядра в queue group `arm-core`: каждое сообщение обработано ровно одним (без дублей)."""
subject = _unique_subject()
received: dict[str, list[str]] = {"a": [], "b": []}
def make_on_connected(name: str) -> Any:
async def handler(msg: Msg) -> None:
assert msg.headers is not None
received[name].append(msg.headers[NATS_MSG_ID_HEADER])
async def on_connected(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
return on_connected
instance_a = await _start(
NatsConnection(_nats_settings(), on_connected=make_on_connected("a"), result_subject=subject)
)
instance_b = await _start(
NatsConnection(_nats_settings(), on_connected=make_on_connected("b"), result_subject=subject)
)
assert instance_a.queue == instance_b.queue == RESULT_QUEUE_GROUP
total = 20
try:
for i in range(total):
await instance_a.publish(subject, f"result {i}".encode(), f"result:job-{i}:1")
await _wait_until(lambda: len(received["a"]) + len(received["b"]) >= total)
await asyncio.sleep(0.2) # дубль, если бы он был, успел бы прийти
finally:
await instance_a.stop()
await instance_b.stop()
all_ids = received["a"] + received["b"]
assert len(all_ids) == total
assert Counter(all_ids) == Counter(f"result:job-{i}:1" for i in range(total))
async def test_subscription_survives_handler_error():
subject = _unique_subject()
received: list[bytes] = []
async def handler(msg: Msg) -> None:
if msg.data == b"boom":
raise ValueError("сломанное сообщение")
received.append(msg.data)
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject))
try:
await conn.publish(subject, b"boom", "m:1")
await conn.publish(subject, b"ok", "m:2")
await _wait_until(lambda: received == [b"ok"])
finally:
await conn.stop()
async def test_start_against_unreachable_server_does_not_block_and_stops_cleanly():
"""Регрессия T4.2: недоступный NATS при старте — start() не ждёт, is_connected=False, stop() быстрый."""
settings = NATSSettings(NATS_URL="nats://127.0.0.1:54999")
conn = NatsConnection(settings)
started = time.monotonic()
await conn.start()
assert time.monotonic() - started < START_MAX_SECONDS
await asyncio.sleep(0.3)
assert not conn.is_connected
with pytest.raises(NatsPublishError):
await conn.publish("test.unreachable", b"{}", "m:1")
started = time.monotonic()
await conn.stop()
assert time.monotonic() - started < STOP_MAX_SECONDS
assert not conn.is_connected
async def test_plain_client_receives_publish_from_connection():
"""Сторонний подписчик (как сервис распознавания) видит заголовок `Nats-Msg-Id` от `NatsConnection.publish`."""
subject = _unique_subject()
nc = NATS()
await nc.connect(_nats_settings().NATS_URL, connect_timeout=2, max_reconnect_attempts=1)
subscription = await nc.subscribe(subject)
conn = await _start(NatsConnection(_nats_settings()))
try:
await conn.publish(subject, b"payload", "job-x:2")
msg = await subscription.next_msg(timeout=5)
finally:
await conn.stop()
await nc.close()
assert msg.data == b"payload"
assert msg.headers == {NATS_MSG_ID_HEADER: "job-x:2"}