Files
ss-tools/specs/032-translate-requests-httpx/contracts/modules.md
2026-06-04 19:41:08 +03:00

23 KiB
Raw Blame History

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-аутентификацией и глобальным perenv семафором. @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: Клиенты долгоживущие perenvironment через SupersetClientRegistry (синглтон). Создаются при первом обращении к env_id, закрываются на FastAPI shutdown. Requestscoped клиенты запрещены в 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 для ratelimit выбран, чтобы не раскрывать upstream 429 как отдельный код ошибки — все ошибки Superset маппятся в 502/503/504 для унификации клиентского контракта.

Auth Refresh Safety

Обновление CSRF/authтокенов защищено asyncio.Lock perenvironment через 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) обновляются:

  • Все методы: defasync 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 Глобальный perenv семафор невозможен без синглтона. Requestscoped клиенты теряют connection pooling. Shutdown без реестра оставляет висящие соединения. @REJECTED Perrequest клиенты — теряют connection pooling, семафор не работает. Inline создание в каждом usecase — утечки соединений.

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 События доставлены через fanout на все subscriber queues. При task.cancel() — финальное событие cancelled, дальнейшая публикация для task_id прекращается. @SIDE_EFFECT Публикация событий; WebSocket-отправка логов. @RELATION DEPENDS_ON -> [EXT:asyncio:Queue] @RATIONALE asyncio.Queue интегрируется с event loop'ом. maxsize предотвращает неограниченный рост памяти. Отбрасывание вместо блокировки защищает задачу от замедления. Fanout (одна очередь на подписчика) гарантирует 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