212 lines
31 KiB
Markdown
212 lines
31 KiB
Markdown
# Feature Specification: Async Backend Refactoring
|
||
|
||
**Feature Branch**: `032-translate-requests-httpx`
|
||
**Created**: 2026-06-04
|
||
**Status**: Draft
|
||
**Input**: Полный рефакторинг всех модулей backend на асинхронный неблокирующий ввод-вывод: замена синхронного `requests` на `httpx.AsyncClient`, асинхронное взаимодействие с БД, отказ от `time.sleep` в пользу `asyncio.sleep`, полная ликвидация блокировки event loop'а FastAPI при любых внешних вызовах.
|
||
|
||
## Problem Statement
|
||
|
||
При одном воркере uvicorn **любая** синхронная блокирующая операция (HTTP-вызов к Superset, LLM, SMTP, Telegram, Slack; файловые операции `shutil`; `time.sleep` в циклах поллинга; Git-клонирование) останавливает **все** входящие запросы. Это делает систему неюзабельной при активной работе даже одного оператора и полностью непригодной для командного использования.
|
||
|
||
### Scope: ~15 файлов, ~45 блокирующих вызовов
|
||
|
||
**Корневая причина**: `APIClient` (`core/utils/network.py`, 542 строки) — центральный HTTP-клиент на `requests.Session`. Через него проходит **весь** трафик ss-tools к Superset. Все 13 методов-миксинов `SupersetClient` используют его синхронно. Любой запрос к Superset (получение дашбордов, экспорт, импорт, SQL Lab, chart data, скриншоты) блокирует event loop.
|
||
|
||
### Migration Strategy
|
||
|
||
**Выбрано: Big-bang (Option A).** Все синхронные вызовы `requests`, `time.sleep`, синхронный `SupersetClient` заменяются в одном PR. Синхронный код не сохраняется для обратной совместимости — асинхронный клиент становится единственным. `AsyncSupersetClient` и sync `SupersetClient` сливаются в один асинхронный клиент.
|
||
|
||
### Модули, требующие рефакторинга
|
||
|
||
| Priority | Module | Что блокирует |
|
||
|----------|--------|---------------|
|
||
| **P1** | `core/utils/network.py` — APIClient | Центральный хаб: `requests.Session`, все методы синхронные |
|
||
| **P1** | `core/superset_client/` — SupersetClient + миксины | 13 миксинов × синхронные методы, зависят от APIClient |
|
||
| **P2** | `plugins/translate/` — LLM HTTP, SQL Lab, preview | `requests.post` к LLM, `client.network.request` к Superset, `time.sleep` в поллинге |
|
||
| **P2** | `api/routes/dashboards/` — thumbnail, detail | Создаёт синхронный SupersetClient прямо в `async def` ручке |
|
||
| **P3** | `plugins/backup.py` — BackupPlugin | Экспорт дашбордов, файловый I/O — внутри `async def execute()` |
|
||
| **P3** | `plugins/git_plugin.py` — GitPlugin | `shutil.*`, `SupersetClient`, Git-клонирование — внутри `async def` |
|
||
| **P3** | `plugins/llm_analysis/plugin.py` — dashboard logs | `client.network.request` внутри `async def _fetch_dashboard_logs()` |
|
||
| **P3** | `core/utils/fileio.py` — файловые операции | `shutil.rmtree`, `read_bytes`, `open()` — вызываются из плагинов |
|
||
| **P3** | `core/utils/superset_compilation_adapter.py` | `client.network.request` для компиляции SQL — вызывается из dataset_review |
|
||
| **P4** | `services/notifications/providers.py` — SMTP, Telegram, Slack | `smtplib.SMTP`, `requests.post` — внутри `async def send()` |
|
||
| **P4** | `services/git/_base.py` — Git-операции | `shutil`, GitPython-клонирование |
|
||
| **P4** | `plugins/storage/plugin.py` — файлы | `shutil.copyfileobj()` в `async def save_file()` |
|
||
| **P4** | `api/routes/` — прочие ручки | Остаточные синхронные вызовы |
|
||
| **P4** | `core/task_manager/` — Task Manager | `ThreadPoolExecutor` заменяется на `asyncio.create_task`; WebSocket-логирование и lifecycle — асинхронные |
|
||
|
||
### Эталонные паттерны и существующая инфраструктура
|
||
|
||
- `backend/src/core/utils/async_network.py` — `AsyncNetworkModule` [C:5] с `AsyncAPIClient` [C:3] — **уже существует**. Полноценный асинхронный клиент на `httpx.AsyncClient` с `SupersetAuthCache`, 401-retry, error translation. Требуется расширение: добавление `asyncio.Semaphore`, connection pool config.
|
||
- `backend/src/core/utils/network.py` — `NetworkModule` [C:3] с `APIClient` [C:3] + `SupersetAuthCache` — синхронный клиент, будет заменён (Tombstone). `SupersetAuthCache` — общий для sync/async, сохраняется.
|
||
- `plugins/llm_analysis/service.py` — `_call_sync()` + `asyncio.to_thread()` — эталонный паттерн для обёртки sync-вызовов (БД, файловые операции).
|
||
- `plugins/llm_analysis/service.py` — `AsyncOpenAI` + `httpx.AsyncClient` — эталон для асинхронных LLM-вызовов.
|
||
- `core/async_superset_client.py` — существующий `AsyncSupersetClient` будет объединён с sync `SupersetClient`.
|
||
|
||
## Clarifications
|
||
|
||
### Session 2026-06-04
|
||
|
||
- Q: Migration strategy — dual-stack staged vs big-bang vs wrapper-only vs full async SQLAlchemy? → A: Option A — Big-bang: заменить все синхронные вызовы в одном PR. Sync-код удаляется, async становится единственным путём.
|
||
- Q: Test mock library for httpx — pytest-httpx vs respx vs MockTransport vs keep requests_mock? → A: Option A — `pytest-httpx`. Стандартный мок для httpx, минимум изменений в assertions, нативная интеграция с pytest.
|
||
- Q: Connection pool & concurrency limits — no limits vs semaphore per env vs semaphore per operation type? → A: Option B — `asyncio.Semaphore` на каждое окружение Superset (по умолчанию 20). Все операции конкурируют за один пул, first-come-first-served.
|
||
- Q: Semaphore exhaustion behavior — instant fail vs wait with timeout vs unlimited queue? → A: Option B — ожидание с таймаутом 30 секунд (настраивается). При превышении таймаута → `503 Service Unavailable`.
|
||
- Q: Background tasks & ThreadPoolExecutor — per-task event loop vs full Task Manager async vs hybrid? → A: Option B — Task Manager полностью переходит на asyncio: `asyncio.create_task` вместо `ThreadPoolExecutor` для запуска задач.
|
||
|
||
## User Scenarios & Testing
|
||
|
||
### User Story 1 — Неблокирующее ядро: Superset API (Priority: P1)
|
||
|
||
**Фундамент всего рефакторинга.** Любое взаимодействие с Superset API — получение списка дашбордов, экспорт/импорт, SQL Lab execute, chart data, скриншоты, информация о датасетах — не блокирует обработку других HTTP-запросов. `APIClient` заменяется на асинхронный транспорт, `SupersetClient` становится полностью асинхронным.
|
||
|
||
**Why this priority**: Все плагины (translate, backup, git, llm_analysis, migration) зависят от SupersetClient. Без его асинхронности невозможен рефакторинг ни одного плагина.
|
||
|
||
**Independent Test**: Отправить три параллельных запроса к разным эндпоинтам Superset API через систему. Замерить общее время — должно быть ≈ max(time1, time2, time3), а не sum.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** настроено окружение Superset, **When** одновременно вызываются `get_dashboards()` (пагинированный), `get_dataset_detail()` и `export_dashboard()`, **Then** все три вызова выполняются конкурентно, event loop не блокируется.
|
||
2. **Given** Superset отвечает медленно (5 секунд на дашборд), **When** одновременно приходит обычный GET-запрос (например, `/health`), **Then** GET-запрос получает ответ мгновенно, не дожидаясь Superset.
|
||
3. **Given** асинхронный SupersetClient, **When** происходит сетевая ошибка (connection refused), **Then** исключение всплывает только в запросе-инициаторе, не затрагивая другие конкурентные запросы.
|
||
|
||
---
|
||
|
||
### User Story 2 — Параллельный Translate: preview + schema check (Priority: P2)
|
||
|
||
Оператор настраивает переводческую задачу. Нажимает «Предпросмотр» (preview) на вкладке Preview — система загружает данные из Superset, отправляет в LLM. **Не дожидаясь**, переключается на Target Config и нажимает «Проверить схему» — система немедленно обрабатывает запрос, возвращает результат проверки схемы, **не дожидаясь окончания предпросмотра**.
|
||
|
||
**Why this priority**: Прямая жалоба пользователя. Этот сценарий стал триггером всего рефакторинга.
|
||
|
||
**Independent Test**: POST `/translate/jobs/A/preview` (sample_size=10) и одновременно POST `/translate/check-target-schema`. Проверка схемы возвращает ответ до завершения предпросмотра.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** запущен предпросмотр с LLM-вызовом (8-15 секунд), **When** немедленно отправляется запрос проверки схемы с поллингом SQL Lab (до 15 попыток), **Then** проверка схемы завершается до окончания предпросмотра.
|
||
2. **Given** активный поллинг SQL Lab (`time.sleep` заменён на `asyncio.sleep`), **When** одновременно приходит лёгкий GET-запрос, **Then** он обрабатывается без задержки.
|
||
3. **Given** выполняются три параллельных LLM-вызова (разные джобы, разные операторы), **When** один из них получает rate-limit (429) и уходит в backoff, **Then** два других продолжают выполняться без задержки.
|
||
|
||
---
|
||
|
||
### User Story 3 — Все плагины: backup, git, llm_analysis, storage (Priority: P3)
|
||
|
||
Резервное копирование дашбордов (backup), деплой из Git, LLM-анализ дашбордов, загрузка файлов в storage — все эти операции выполняются асинхронно, не блокируя другие запросы.
|
||
|
||
**Why this priority**: После решения P1 (SupersetClient) и P2 (translate) — остальные плагины становятся «низко висящими фруктами». Их рефакторинг сводится к замене синхронных клиентов на асинхронные.
|
||
|
||
**Independent Test**: Запустить backup всех дашбордов (50+) и одновременно выполнить deploy из Git. Обе операции выполняются конкурентно.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** запущен бэкап 50 дашбордов (каждый экспортируется и сохраняется на диск), **When** одновременно запускается деплой дашборда из Git, **Then** деплой не ждёт окончания бэкапа.
|
||
2. **Given** запущен LLM-анализ дашборда (загрузка скриншотов, вызов LLM), **When** одновременно загружается файл в storage, **Then** загрузка файла не блокируется LLM-вызовом.
|
||
3. **Given** Git-операция клонирования репозитория (5-10 секунд), **When** одновременно пользователь открывает список дашбордов, **Then** список загружается без задержки.
|
||
|
||
---
|
||
|
||
### User Story 4 — Уведомления и фоновая отправка (Priority: P4)
|
||
|
||
Отправка уведомлений (SMTP, Telegram, Slack) по завершении задач, scheduled reports, alert'ы — все асинхронны и не блокируют основные запросы.
|
||
|
||
**Why this priority**: Уведомления — фоновая операция. Их блокировка не критична для UX, но `smtplib.SMTP` и `requests.post` в `async def send()` — нарушение контракта.
|
||
|
||
**Independent Test**: Отправить SMTP-уведомление (медленный SMTP-сервер, 3 секунды на подключение) одновременно с обычным API-запросом. API-запрос не задерживается.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** отправляется Telegram-уведомление (`requests.post` заменён на `httpx.AsyncClient.post`), **When** одновременно приходит запрос на получение списка джоб, **Then** запрос списка не ждёт отправки уведомления.
|
||
2. **Given** SMTP-сервер недоступен (connection timeout 10 секунд), **When** отправляется email-уведомление, **Then** таймаут не блокирует другие запросы, ошибка логируется.
|
||
|
||
---
|
||
|
||
### Edge Cases
|
||
|
||
- **Отмена запроса клиентом (disconnect)**: При разрыве соединения все связанные внешние вызовы (HTTP, поллинг, файловые операции) должны быть отменены. Никаких «висящих» соединений и утечек.
|
||
- **Таймауты внешних сервисов**: Каждый внешний вызов имеет настраиваемый таймаут. Превышение таймаута — исключение только для запроса-инициатора.
|
||
- **Сетевые ошибки**: DNS failure, connection refused, TLS handshake failure — корректная обработка без влияния на другие запросы.
|
||
- **CPU-bound парсинг**: JSON-парсинг больших ответов LLM, валидация данных — выполняются через `run_blocking(kind='cpu', fn=...)`.
|
||
- **Файловые операции с большими файлами**: Экспорт дашбордов (ZIP до 50MB), сохранение на диск — через `aiofiles` или `run_blocking(kind='file', fn=...)`.
|
||
- **Git-клонирование**: Длительная операция (до 30 секунд) — выполняется в thread pool, не блокирует event loop.
|
||
- **Гонка состояний (race conditions)**: При конкурентных запросах к одному ресурсу (например, одновременный deploy и backup одного дашборда) система должна обеспечивать консистентность данных.
|
||
- **Исчерпание пула соединений к Superset**: Когда все 20 слотов семафора заняты, новые запросы ожидают до 30 секунд. При превышении таймаута — ошибка `503 Service Unavailable` с сообщением «Superset connection pool exhausted». Другие API (не использующие Superset) продолжают работать.
|
||
- **Конкурентная аутентификация**: Несколько корутин могут одновременно обнаружить истёкший CSRF‑токен. Обновление токена защищено `asyncio.Lock` per‑environment. Только одна корутина выполняет refresh, остальные ждут и переиспользуют результат.
|
||
- **Graceful shutdown**: При остановке сервера долгоживущие клиенты (`httpx.AsyncClient`), семафоры, очереди EventBus и активные задачи должны быть корректно закрыты в течение 30 секунд.
|
||
- **Зависший поток в `run_blocking`**: Если операция в `run_blocking` зависает (например, Git‑клонирование не отвечает), общий таймаут через `asyncio.wait_for` прерывает ожидание, но поток продолжает работу в фоне. Слот executor'а освобождается после возврата функции. Система логирует превышение таймаута и продолжает работу.
|
||
- **Таймаут поллинга SQL Lab**: Вся операция `execute_and_poll` (включая все попытки поллинга) должна иметь общий таймаут (настраиваемый, по умолчанию 120 секунд), чтобы не висеть бесконечно при зависшем SQL‑запросе на стороне Superset.
|
||
|
||
## Requirements
|
||
|
||
### Functional Requirements
|
||
|
||
**Core Infrastructure (P1):**
|
||
- **FR-001**: `APIClient` (базовый HTTP-клиент) должен быть переведён на асинхронный транспорт (`httpx.AsyncClient`). Все методы (request, authenticate, upload_file, fetch_paginated_*) становятся асинхронными.
|
||
- **FR-002**: `SupersetClient` и все 13 миксинов переводятся на асинхронный `APIClient`. Синхронный `SupersetClient` и `AsyncSupersetClient` объединяются в единый асинхронный клиент. Синхронный код удаляется полностью (big-bang).
|
||
- **FR-003**: Все вызовы `SupersetClient` из `async def` ручек должны перейти на асинхронный клиент. Создание синхронного `SupersetClient(env)` внутри `async def` запрещено.
|
||
|
||
**Translate Plugin (P2):**
|
||
- **FR-004**: LLM-вызовы (`_llm_http.py`, `preview_llm_client.py`) должны использовать `httpx.AsyncClient` вместо `requests`. Rate-limit backoff — `asyncio.sleep` вместо `time.sleep`.
|
||
- **FR-005**: SQL Lab executor (`superset_executor.py`) — поллинг с `asyncio.sleep` вместо `time.sleep`. Execute и poll — асинхронные методы.
|
||
- **FR-006**: Загрузка данных из Superset (chart data, source rows) — через асинхронный клиент.
|
||
- **FR-007**: Preview и schema check должны выполняться конкурентно без взаимной блокировки.
|
||
|
||
**All Plugins (P3):**
|
||
- **FR-008**: BackupPlugin — экспорт дашбордов и файловый I/O через асинхронный клиент + `run_blocking(kind='file', fn=...)` / `aiofiles`.
|
||
- **FR-009**: GitPlugin — `shutil` операции и Git-клонирование через `run_blocking(kind='file'/'git', fn=...)`.
|
||
- **FR-011**: Storage Plugin — `shutil.copyfileobj()` заменён на `run_blocking(kind='file', fn=...)` или `aiofiles`.
|
||
- **FR-015**: Git-сервис (`services/git/_base.py`) — файловые операции через `run_blocking(kind='file'/'git', fn=...)`.
|
||
|
||
**Cross-cutting:**
|
||
- **FR-016**: Все внешние HTTP-вызовы должны иметь настраиваемый таймаут.
|
||
- **FR-017**: Отмена клиентского запроса cascade-отменяет связанные внешние вызовы: awaitable HTTP/polling — немедленная отмена (httpx + CancelledError); операции в `run_blocking` (Git, файлы, БД) помечаются cancelled, завершаются до ближайшего checkpoint и освобождают ресурсы после возврата. Принудительная остановка потока не гарантируется.
|
||
- **FR-018**: Существующие success-коды (200, 201) и форматы ответов всех эндпоинтов остаются неизменными. Допускается добавление новых failure-кодов для ранее не обрабатывавшихся режимов: 503 (pool exhaustion), 504 (upstream timeout).
|
||
- **FR-019**: Лёгкие запросы (GET /jobs, GET /dashboards) не должны деградировать по времени ответа после рефакторинга.
|
||
- **FR-020**: CoT-логирование (REASON/REFLECT/EXPLORE) сохраняется на всех асинхронных путях.
|
||
- **FR-021**: Все операции с БД, вызываемые из асинхронного контекста, должны быть обёрнуты в `run_blocking(kind='db', fn=...)`. SQLAlchemy‑сессии не пересекают границу потоков (FR-025).
|
||
|
||
**Task Manager (P4):**
|
||
- **FR-022**: `ThreadPoolExecutor` для запуска задач заменяется на `asyncio.create_task`. Задачи выполняются в основном event loop'е.
|
||
- **FR-023**: WebSocket-логирование задач переводится на асинхронную отправку (без блокирующего I/O). `EventBus` использует `asyncio.Queue(maxsize=10000, настраивается)`; при переполнении новые события отбрасываются с предупреждением в лог (не блокируют задачу). При `task.cancel()` публикация логов для task_id прекращается, формируется финальное событие `{"type":"cancelled"}`. Уже поставленные в очередь события могут быть доставлены.
|
||
- **FR-024**: Lifecycle задач (запуск, отмена, статус) остаётся обратно совместимым — контракты `TaskManager` не меняются для потребителей.
|
||
|
||
**Safety & Contracts:**
|
||
- **FR-025**: SQLAlchemy‑сессии создаются, используются, коммитятся/откатываются и закрываются **внутри одного worker‑потока**. Никакие ORM‑объекты не пересекают границу потока без detach/serialization. DB‑адаптер владеет жизненным циклом сессии.
|
||
- **FR-026**: Обновление CSRF/auth‑токенов защищено `asyncio.Lock` per‑environment. Retry после 401 разрешён только для идемпотентных методов (GET). Для неидемпотентных (POST import, PUT) — ошибка всплывает вызывающему без автоматического retry.
|
||
- **FR-027**: Graceful shutdown: прекратить приём новых задач, отменить активные asyncio‑задачи, закрыть все `httpx.AsyncClient` через `SupersetClientRegistry`, слить EventBus, пометить незавершённые задачи как CANCELLED в течение 30 секунд (настраивается).
|
||
- **FR-028**: Планировщик задач (APScheduler) должен быть переведён на `AsyncIOScheduler`, либо все вызовы `TaskManager.run_task()` из scheduled jobs должны выполняться через `asyncio.run_coroutine_threadsafe()` с ожиданием результата.
|
||
|
||
### Non-Functional Requirements
|
||
|
||
- **NFR-001**: При одном воркере uvicorn 3 параллельных «тяжёлых» запроса должны выполняться конкурентно (пересекающиеся временные интервалы), а не последовательно.
|
||
- **NFR-002**: CPU-bound операции (JSON-парсинг > 1MB, валидация, хеширование) выполняются через `run_blocking(kind='cpu', fn=...)`.
|
||
- **NFR-003**: Утечки соединений (незакрытые HTTP-клиенты, файловые дескрипторы) после 500 последовательных запросов к любым эндпоинтам равны нулю.
|
||
- **NFR-004**: Время выполнения отдельной операции в отсутствие конкуренции (baseline) не увеличивается более чем на 10% относительно синхронной версии, замеренной до рефакторинга.
|
||
- **NFR-005**: Все новые зависимости (httpx, aiofiles, aiosmtplib) должны быть добавлены в `requirements.txt`.
|
||
- **NFR-006**: Количество одновременных соединений к одному окружению Superset ограничивается `asyncio.Semaphore` (по умолчанию 20, настраивается через конфигурацию окружения). Семафор — **глобальный per‑environment** через `SupersetClientRegistry` (синглтон), не per‑client. При исчерпании пула запрос ожидает до 30 секунд (настраивается), затем возвращает `503 Service Unavailable`.
|
||
- **NFR-007**: Блокирующие адаптеры (Git, файлы, БД) используют именованные bounded‑executor'ы через `loop.run_in_executor(named_executor, fn)`: `db_executor`, `file_executor`, `git_executor` с `max_workers` и queue timeout (по умолчанию 10 воркеров каждый). Прямое использование `asyncio.to_thread` (default executor) запрещено для production‑путей. Рекомендуется helper `run_blocking(kind, fn, *args, timeout)` в `core/utils/executors.py`.
|
||
- **NFR-008**: На shutdown: прекратить приём новых задач, отменить активные asyncio‑задачи с graceful‑timeout 30 секунд, закрыть все `httpx.AsyncClient` через `SupersetClientRegistry`, слить/закрыть EventBus, пометить незавершённые задачи как CANCELLED.
|
||
- **NFR-009**: Рекомендуемые runtime‑метрики (post‑MVP): `event_loop_lag_ms`, `superset_pool_in_use{env}`, `superset_pool_wait_ms{env}`, `httpx_connections_open`, `task_cancel_total`, `threadpool_queue_depth`.
|
||
|
||
### Key Entities
|
||
|
||
- **APIClient**: Центральный HTTP-клиент — из синхронного `requests.Session` становится асинхронным `httpx.AsyncClient`. Точка входа для всех внешних HTTP-вызовов.
|
||
- **SupersetClient**: Фасад над APIClient с доменными методами (get_dashboards, export_dashboard, get_dataset_detail, etc.) — все методы становятся асинхронными.
|
||
- **SQL Lab Executor**: Сервис отправки SQL и поллинга результатов — поллинг становится неблокирующим.
|
||
- **LLM API Client**: Клиент для OpenAI-совместимых API — транспорт меняется на `httpx.AsyncClient`.
|
||
- **Notification Providers**: SMTP, Telegram, Slack — каждый провайдер становится действительно асинхронным.
|
||
- **Plugin Execute Contract**: Все плагины реализуют `async def execute()` — теперь без синхронных блокирующих вызовов внутри.
|
||
- **Task Manager**: Менеджер фоновых задач — переходит с `ThreadPoolExecutor` на `asyncio.create_task`. Задачи разделяют основной event loop.
|
||
- **SupersetClientRegistry**: Синглтон-реестр долгоживущих клиентов по `env_id` — хранит shared `httpx.AsyncClient` + shared `asyncio.Semaphore` + auth‑lock. Закрывает все клиенты на shutdown.
|
||
|
||
## Success Criteria
|
||
|
||
### Measurable Outcomes
|
||
|
||
- **SC-001**: Проверка схемы (`/check-target-schema`), отправленная одновременно с предпросмотром (`/preview`), завершается до окончания предпросмотра — результат доступен пользователю параллельно, а не последовательно.
|
||
- **SC-002**: Три параллельных «тяжёлых» запроса (preview + backup + schema check) при одном воркере uvicorn завершаются за время ≈ max(t1, t2, t3), а не sum.
|
||
- **SC-003**: Легковесный GET-запрос (`/health`, `/translate/jobs`) во время активного поллинга SQL Lab (до 30 секунд) получает ответ за < 100ms.
|
||
- **SC-004**: Экспорт 50 дашбордов (backup) и деплой дашборда (git) выполняются конкурентно без взаимной блокировки.
|
||
- **SC-005**: Существующие поведенческие assertions в тестах backend остаются эквивалентными (логика проверок не меняется). Тестовые фикстуры и async‑механика могут измениться (замена `requests_mock` → `pytest_httpx`, добавление `pytest.mark.asyncio`).
|
||
- **SC-006**: Ноль утечек соединений после 500 последовательных запросов к любым эндпоинтам.
|
||
- **SC-007**: Время выполнения предпросмотра перевода не увеличилось более чем на 10% относительно синхронной версии.
|
||
- **SC-008**: Количество «грязных» `async def`-ручек, содержащих синхронные блокирующие вызовы, равно нулю (верифицируется статическим анализом).
|