# Contracts: Async Backend Refactoring **Feature**: `032-translate-requests-httpx` **Date**: 2026-06-04 ## @{ AsyncAPIClient [C:4] [TYPE Class] @BRIEF Асинхронный HTTP-клиент для Superset API — использует `httpx.AsyncClient` с connection pooling, CSRF-аутентификацией и глобальным per‑env семафором. @PRE env_config — валидный EnvironmentConfig с url, username, password. @POST Клиент аутентифицирован, connection pool готов. Все HTTP-методы возвращают response. @SIDE_EFFECT HTTP-вызовы к Superset API; модификация CSRF-токена; захват/освобождение глобального семафора. @RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient] @RELATION DEPENDS_ON -> [SupersetClientRegistry] @RATIONALE httpx выбран вместо aiohttp — уже в зависимостях (0.28.1), лучший API для async HTTP. @REJECTED requests.Session (sync) — блокирует event loop. aiohttp — требует новой зависимости. ### Client Lifecycle **Production runtime**: Клиенты долгоживущие per‑environment через `SupersetClientRegistry` (синглтон). Создаются при первом обращении к `env_id`, закрываются на FastAPI shutdown. Request‑scoped клиенты запрещены в production. **Tests**: разрешён `async with AsyncAPIClient(env) as client:` для изоляции тестов. ### Error Mapping | Условие | Исключение | HTTP Status | |---------|------------|-------------| | Все слоты семафора заняты, таймаут | `SupersetPoolTimeout` | 503 Service Unavailable | | Ошибка аутентификации (401 после retry) | `AuthenticationError` | 502 Bad Gateway | | Таймаут upstream (httpx.Timeout) | `SupersetTimeout` | 504 Gateway Timeout | | Сетевая ошибка (ConnectionError) | `NetworkError` | 502 Bad Gateway | | Rate-limit от Superset (429) | `SupersetRateLimitError` | 502 Bad Gateway | @RATIONALE 502 для rate‑limit выбран, чтобы не раскрывать upstream 429 как отдельный код ошибки — все ошибки Superset маппятся в 502/503/504 для унификации клиентского контракта. ### Auth Refresh Safety Обновление CSRF/auth‑токенов защищено `asyncio.Lock` per‑environment через `SupersetClientRegistry`. Retry после 401 разрешён только для идемпотентных методов (GET). Для неидемпотентных (POST import, PUT) — ошибка всплывает вызывающему без автоматического retry. @DATA_CONTRACT Input: (method, endpoint, params, data, headers, files) → Output: Response dict with status_code, data, headers. ### Контракты методов ``` async def request(method, endpoint, params, data, headers, files, timeout) -> dict @PRE client аутентифицирован, semaphore доступен. @POST HTTP-ответ получен, semaphore освобождён. @SIDE_EFFECT HTTP-вызов к Superset; обновление CSRF-токена при 401. async def authenticate() -> None @POST CSRF-токен получен, сессия валидна. async def upload_file(endpoint, file_path, ...) -> dict @PRE файл существует на диске. @POST файл загружен, ответ получен. async def fetch_paginated_data(endpoint, query) -> list[dict] @POST все страницы загружены, результат агрегирован. async def fetch_paginated_count(endpoint, query) -> int @POST возвращает общее количество записей. ``` ## @} AsyncAPIClient --- ## @{ SupersetClientAuth [C:3] [TYPE Module] @BRIEF CSRF-аутентификация для Superset API — извлечено из `network.py` для соблюдения INV_7 (<400 строк). @RELATION DEPENDS_ON -> [AsyncAPIClient] @RATIONALE Декомпозиция `network.py` (542 строки) на три модуля: `async_client.py`, `auth.py`, `paginated.py`. Аутентификация — отдельная ответственность. @REJECTED Оставить в `network.py` — нарушает INV_7, модуль > 400 строк. ``` async def fetch_csrf_token(client: AsyncAPIClient) -> str @BRIEF GET /api/v1/security/csrf_token/, возвращает CSRF-токен. async def login(client: AsyncAPIClient, username: str, password: str) -> None @BRIEF POST /api/v1/security/login, устанавливает сессионную куку. @POST клиент аутентифицирован, CSRF-токен обновлён. ``` ## @} SupersetClientAuth --- ## @{ SupersetClientPaginated [C:3] [TYPE Module] @BRIEF Пагинированная загрузка данных из Superset API — извлечено из `network.py`. @RELATION DEPENDS_ON -> [AsyncAPIClient] @RATIONALE Пагинация — отдельная ответственность, вынесена для INV_7. ``` async def fetch_paginated(client: AsyncAPIClient, endpoint: str, query: dict) -> list[dict] @BRIEF Итеративная загрузка всех страниц пагинированного ответа. @POST все страницы загружены и агрегированы в единый список. async def fetch_count(client: AsyncAPIClient, endpoint: str, query: dict) -> int @BRIEF Загрузка количества записей (page_size=0, только count). @POST возвращает total_count из ответа Superset. ``` ## @} SupersetClientPaginated --- ## @{ SupersetClient [C:4] [TYPE Class] @BRIEF Единый асинхронный клиент для Superset API. Объединяет sync `SupersetClient` и `AsyncSupersetClient` в полностью асинхронный фасад. Все 13 миксинов — async. @PRE env_config — валидный EnvironmentConfig. @POST Клиент готов к вызовам Superset API. @SIDE_EFFECT Все методы выполняют асинхронные HTTP-вызовы к Superset. @RELATION DEPENDS_ON -> [AsyncAPIClient] @RELATION DEPENDS_ON -> [SupersetClientAuth] @RELATION DEPENDS_ON -> [SupersetClientPaginated] @RATIONALE Big-bang миграция: sync-код удаляется, dual-stack не поддерживается. AsyncSupersetClient поглощается. @REJECTED Dual-stack (sync + async) — отвергнут на этапе clarification. Wrapper-only (asyncio.to_thread) — отвергнут, httpx.AsyncClient эффективнее. ### Сигнатуры ключевых методов (все async def) ``` async def get_dashboards(query=None) -> tuple[int, list[dict]] async def get_dashboard(dashboard_id: str) -> dict async def export_dashboard(dashboard_id: str) -> tuple[bytes, str] async def import_dashboard(file_path: str) -> dict async def get_datasets(query=None) -> tuple[int, list[dict]] async def get_dataset_detail(dataset_id: int) -> dict async def get_database(database_id: int) -> dict async def get_databases(query=None) -> tuple[int, list[dict]] async def get_chart_data(query_context: dict) -> dict async def execute_sql_lab(payload: dict) -> dict async def get_query_status(query_id: str) -> dict async def get_query_results(query_id: str) -> dict async def cache_screenshot(dashboard_id: str) -> dict async def get_thumbnail(dashboard_id: str) -> bytes ``` ### Контракты на 13 миксинов Все миксины (`_base.py`, `_datasets.py`, `_dashboards.py`, `_charts.py`, `_sql_lab.py`, `_database.py`, `_import_export.py`, `_thumbnail.py`, `_logs.py`, `_css.py`, `_annotation.py`, `_filter.py`, `_query_context.py`) обновляются: - Все методы: `def` → `async def` - @RELATION DEPENDS_ON -> [SupersetClient] (было [APIClient] или [SupersetClient]) - @SIDE_EFFECT обновлён: «асинхронные HTTP-вызовы к Superset API» Контракт `AsyncSupersetClient` → Tombstone: - @DEPRECATED: 2026-06-04 - @REPLACED_BY: [SupersetClient] ## @} SupersetClient --- ## @{ SupersetClientRegistry [C:5] [TYPE Class] @BRIEF Синглтон-реестр долгоживущих асинхронных клиентов по env_id. Хранит shared httpx.AsyncClient + shared asyncio.Semaphore + asyncio.Lock для auth refresh. @PRE FastAPI event loop запущен. Environment configs загружены. @POST Клиенты доступны по env_id, закрыты на shutdown. @SIDE_EFFECT Создание/закрытие httpx.AsyncClient; управление семафорами и auth‑локами. @INVARIANT Один клиент и один семафор на env_id за всё время жизни приложения. @INVARIANT Все клиенты закрыты при shutdown. @RELATION DEPENDS_ON -> [AsyncAPIClient] @RATIONALE Глобальный per‑env семафор невозможен без синглтона. Request‑scoped клиенты теряют connection pooling. Shutdown без реестра оставляет висящие соединения. @REJECTED Per‑request клиенты — теряют connection pooling, семафор не работает. Inline создание в каждом use‑case — утечки соединений. ``` async def get_client(env_id: str) -> AsyncAPIClient @BRIEF Получить или создать клиент для окружения. Создание ленивое при первом обращении. @POST Клиент готов, семафор активен. async def shutdown() -> None @BRIEF Закрыть все httpx.AsyncClient, освободить семафоры, очистить auth‑локи. @POST Все сетевые ресурсы освобождены. ``` ## @} SupersetClientRegistry --- ## @{ GracefulShutdown [C:4] [TYPE Block] @BRIEF Контракт graceful shutdown: порядок закрытия ресурсов при остановке FastAPI. @PRE lifespan shutdown event получен. @POST Все ресурсы освобождены, незавершённые задачи помечены CANCELLED. @SIDE_EFFECT Прекращение приёма задач; отмена активных asyncio.Task; закрытие клиентов через SupersetClientRegistry; слив/закрытие EventBus. @INVARIANT Shutdown завершается в течение graceful_timeout (по умолчанию 30 секунд). ``` async def on_shutdown(): 1. stop_accepting_new_tasks() 2. cancel_active_tasks(graceful_timeout=30) 3. await SupersetClientRegistry.shutdown() 4. await EventBus.drain() 5. mark_unfinished_tasks(CANCELLED) ``` ## @} GracefulShutdown --- ## @{ SupersetSqlLabExecutor [C:4] [TYPE Class] @BRIEF Асинхронный исполнитель SQL-запросов через Superset SQL Lab API. Поллинг с `asyncio.sleep`. @PRE env_id — валидное окружение Superset. SQL — валидный и санитизированный. @POST Результат выполнения SQL получен и распарсен. @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset SQL Lab API; поллинг статуса. @RELATION DEPENDS_ON -> [SupersetClient] @RATIONALE Поллинг с `asyncio.sleep` вместо `time.sleep` — не блокирует event loop между попытками. @REJECTED time.sleep в цикле поллинга — блокирует event loop до 120 секунд (60 попыток × 2s). ``` async def execute_sql(sql: str, database_id: int, run_async: bool) -> dict @POST query_id получен. async def poll_execution_status(query_id: str, max_polls: int, poll_interval: float) -> dict @BRIEF Поллинг с asyncio.sleep. Возвращает {"status": "success"|"failed"|"timeout"}. @POST результат выполнения SQL получен или таймаут. async def execute_and_poll(sql: str, database_id: int, ...) -> dict @BRIEF execute_sql + poll_execution_status в одном вызове. ``` ## @} SupersetSqlLabExecutor --- ## @{ LLMHttpClient [C:4] [TYPE Module] @BRIEF Унифицированный асинхронный HTTP-клиент для OpenAI-совместимых LLM API. Замена `_llm_http.py` и `preview_llm_client.py`. @PRE api_key, base_url, model — валидны. @POST LLM-ответ получен и распарсен. @SIDE_EFFECT HTTP POST к LLM API; rate-limit backoff с asyncio.sleep. @RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient] @RATIONALE Объединение двух дублирующихся LLM-клиентов в один модуль. httpx.AsyncClient обеспечивает неблокирующий HTTP. asyncio.sleep вместо time.sleep для rate-limit пауз. @REJECTED requests + time.sleep — блокирует event loop. AsyncOpenAI SDK — не поддерживает кастомные base_url для OpenRouter/LiteLLM/Kilo. ``` async def call_openai_compatible(base_url, api_key, model, prompt, max_tokens, provider_type, disable_reasoning) -> tuple[str, str|None] @BRIEF Отправить prompt и получить (content, finish_reason). @PRE api_key дешифрован, base_url не пустой. @POST (response_text, finish_reason) или ValueError при ошибке. @SIDE_EFFECT HTTP POST + retry при 429 (rate-limit) с exponential backoff через asyncio.sleep. ``` ## @} LLMHttpClient --- ## @{ TaskManager [C:5] [TYPE Class] @BRIEF Менеджер фоновых задач — полный переход с ThreadPoolExecutor на asyncio.create_task. @PRE FastAPI event loop запущен. Плагины реализуют async def execute(). @POST Задачи запущены, выполняются конкурентно в основном event loop. @SIDE_EFFECT Запуск/отмена asyncio.Task; обновление статуса в БД; публикация событий в EventBus. @INVARIANT Задачи не блокируют event loop дольше, чем на 100ms между await-точками. @INVARIANT Отмена задачи cascade-освобождает все ресурсы (HTTP-соединения, семафоры, файловые дескрипторы). @RELATION DEPENDS_ON -> [EventBus] @RELATION DEPENDS_ON -> [EXT:asyncio:Task] @DATA_CONTRACT Input: TaskParams(plugin_name, params, user_id) → Output: TaskResult(status, result, error) @RATIONALE asyncio.create_task вместо ThreadPoolExecutor — задачи разделяют event loop, не требуют тредовой синхронизации, поддерживают нативную отмену через task.cancel(). @REJECTED ThreadPoolExecutor — создаёт изоляцию между задачами, усложняет отмену и WebSocket-логирование. Per-task event loop (asyncio.run) — отвергнут, изоляция мешает общей отмене. ``` async def run_task(plugin_name: str, params: dict, user_id: str) -> str @BRIEF Запустить задачу как asyncio.create_task. Вернуть task_id. @POST задача зарегистрирована, статус PENDING → RUNNING. async def cancel_task(task_id: str) -> None @BRIEF Отменить задачу через task.cancel(). Обработать CancelledError. @POST статус задачи CANCELLED, ресурсы освобождены. async def get_task_status(task_id: str) -> dict @POST {"status": "RUNNING"|"COMPLETED"|"FAILED"|"CANCELLED", ...} ``` ## @} TaskManager --- ## @{ EventBus [C:4] [TYPE Class] @BRIEF Асинхронная шина событий для WebSocket-логирования задач. asyncio.Queue(maxsize=10000, настраивается) вместо queue.Queue. Одна очередь на подписчика. При переполнении — отбрасывание нового события с логированием предупреждения. @PRE TaskManager и WebSocket-ручки инициализированы. @POST События доставлены через fan‑out на все subscriber queues. При task.cancel() — финальное событие cancelled, дальнейшая публикация для task_id прекращается. @SIDE_EFFECT Публикация событий; WebSocket-отправка логов. @RELATION DEPENDS_ON -> [EXT:asyncio:Queue] @RATIONALE asyncio.Queue интегрируется с event loop'ом. maxsize предотвращает неограниченный рост памяти. Отбрасывание вместо блокировки защищает задачу от замедления. Fan‑out (одна очередь на подписчика) гарантирует broadcast. @REJECTED queue.Queue + call_soon_threadsafe — избыточно. Одна общая очередь — не гарантирует broadcast. ``` async def publish(event: dict) -> None @POST событие добавлено в очередь. async def subscribe() -> AsyncIterator[dict] @BRIEF Асинхронный генератор — читает события из очереди. @POST yield каждого события. При отмене (WebSocket disconnect) — выход из генератора. ``` ## @} EventBus --- ## @{ TaskLifecycle [C:4] [TYPE Class] @BRIEF Асинхронный жизненный цикл задачи: подготовка, выполнение плагина, финализация. @PRE TaskManager запущен, плагин загружен. @POST Задача выполнена, результат записан в БД, событие опубликовано. @SIDE_EFFECT Запуск плагина; обновление статуса задачи; публикация событий. @RELATION DEPENDS_ON -> [TaskManager] @RELATION DEPENDS_ON -> [EventBus] @RATIONALE Переход на async context manager вместо ThreadPoolExecutor.submit(). ``` async def execute_task(task_id: str, plugin_name: str, params: dict) -> None @BRIEF Полный цикл: PENDING → RUNNING → execute → COMPLETED/FAILED. @SIDE_EFFECT Сохраняет статус в БД; публикует start/progress/complete события. ``` ## @} TaskLifecycle --- ## @{ SMTPProvider [C:3] [TYPE Class] @BRIEF Асинхронный SMTP-провайдер уведомлений. aiosmtplib вместо smtplib. @PRE host, port, username, password — валидны. @POST Email отправлен или ошибка залогирована. @SIDE_EFFECT Подключение к SMTP-серверу, отправка письма. @RELATION DEPENDS_ON -> [EXT:aiosmtplib:SMTP] @RATIONALE aiosmtplib — зрелая async-замена smtplib. Не блокирует event loop при TCP-подключении и SMTP-командах. @REJECTED smtplib.SMTP — блокирует event loop. asyncio.to_thread для SMTP — работает, но aiosmtplib даёт нативную интеграцию с event loop. ``` async def send(recipient: str, subject: str, body: str) -> None @POST письмо отправлено или ошибка залогирована (не пробрасывается). ``` ## @} SMTPProvider --- ## @{ TelegramProvider [C:3] [TYPE Class] @BRIEF Асинхронный Telegram-провайдер уведомлений. httpx.AsyncClient вместо requests. @PRE bot_token, chat_id — валидны. @POST Сообщение отправлено. @SIDE_EFFECT HTTP POST к Telegram Bot API. @RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient] @RATIONALE httpx.AsyncClient переиспользуется из пула соединений — эффективнее requests. @REJECTED requests.post — блокирует event loop. ``` async def send(recipient: str, subject: str, body: str) -> None @POST сообщение отправлено через Telegram Bot API. ``` ## @} TelegramProvider --- ## @{ NotificationService [C:3] [TYPE Class] @BRIEF Сервис отправки уведомлений — вызывает провайдеры асинхронно. @PRE NotificationConfig загружен, провайдеры инициализированы. @POST Уведомление отправлено через все настроенные каналы. @SIDE_EFFECT Вызовы провайдеров (SMTP, Telegram, Slack) — каждый выполняется независимо. @RELATION DEPENDS_ON -> [SMTPProvider] @RELATION DEPENDS_ON -> [TelegramProvider] @RELATION DEPENDS_ON -> [SlackProvider] ``` async def dispatch_report(report: Report, config: NotificationConfig) -> None @BRIEF Отправить отчёт через все включённые каналы параллельно (asyncio.gather). @POST все провайдеры вызваны, ошибки залогированы индивидуально. ``` ## @} NotificationService --- ## @{ FileIO [C:3] [TYPE Module] @BRIEF Гибридные асинхронные файловые операции: aiofiles для больших файлов, asyncio.to_thread для мелких. @RATIONALE aiofiles для потокового I/O больших файлов, asyncio.to_thread для операций, не поддерживаемых aiofiles (shutil.rmtree, рекурсивные операции). @REJECTED Только asyncio.to_thread — для 50MB ZIP блокирует тред на секунды. Только aiofiles — не поддерживает shutil-операции. ``` async def read_dashboard_from_disk(file_path: str) -> bytes @BRIEF Чтение дашборда с диска. >1MB → aiofiles, иначе asyncio.to_thread. async def save_and_unpack_dashboard(zip_content: bytes, target_dir: str) -> None @BRIEF Сохранение и распаковка ZIP. Запись через asyncio.to_thread. async def cleanup_directory(path: str) -> None @BRIEF Удаление директории через asyncio.to_thread(shutil.rmtree). ``` ## @} FileIO --- ## Tombstoned Contracts ### @{ APIClient [Tombstone] [TYPE Class] @DEPRECATED 2026-06-04 — заменён на AsyncAPIClient. Синхронный requests.Session больше не используется. @REPLACED_BY -> [AsyncAPIClient] ## @} APIClient ### @{ AsyncSupersetClient [Tombstone] [TYPE Class] @DEPRECATED 2026-06-04 — поглощён единым асинхронным SupersetClient. @REPLACED_BY -> [SupersetClient] ## @} AsyncSupersetClient ### @{ LLMHttpClient_Legacy [Tombstone] [TYPE Module] @DEPRECATED 2026-06-04 — `_llm_http.py` и `preview_llm_client.py` заменены унифицированным `LLMHttpClient`. @REPLACED_BY -> [LLMHttpClient] ## @} LLMHttpClient_Legacy