23 KiB
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]