fix(tradein/auth): bcrypt вне событийного цикла + настоящий потолок темпа логинов (#2665) #2712

Merged
bot-backend merged 3 commits from fix/2665-bcrypt-offloop-and-throttle into main 2026-08-06 09:02:11 +00:00

3 commits

Author SHA1 Message Date
d5e15423d8 fix(tradein/auth): дефолты потолка закреплены тестом, границы настроек, точные обоснования
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m6s
Ревью показало, что «разрыва вынесено-без-потолка не существует» верно только
по коду: `LOGIN_PASSWORD_VERIFY_WORKERS=32` в .env.runtime даёт ровно это
состояние, без единой правки и без ревью — и наступит оно именно так, под
предлогом «входы тормозят».

M1. Тест про темп вычисляет ожидаемый потолок из той же настройки, которую
охраняет, поэтому мутант дефолтов (workers 1→16, inflight 4→64) оставлял все
58 тестов зелёными. Дефолты теперь стережёт отдельный тест: workers==1 и
очередь строго уже пула соединений (5+10). На том же мутанте краснеет.
Комментарий у теста темпа больше не обещает того, чего тот не делает.

L2. `ge=1` на обе настройки. Проверено запуском: 0/-1 в workers роняли
ThreadPoolExecutor на импорте (crash-loop контейнера), 0 в max_inflight отдавал
429 на КАЖДЫЙ вход навсегда и молча — а «0» это естественная попытка выключить
лимит. Теперь отказ на валидации настроек, с именем поля.

Info. Комментарий объяснял фикс не той причиной: отмена не набивает очередь —
не начатую работу `cancel()` снимает. Настоящий вред прежней редакции —
освобождение слота при отмене УЖЕ НАЧАТОЙ сверки (поток занят, а слот числится
свободным), это и ловит тест. Обоснование переписано в коде и в тесте.

Плюс две оговорки: потолок множится и на `WEB_CONCURRENCY` (uvicorn читает
число процессов оттуда, а .env.runtime правится руками), а правило «из async
def только verify_password_bounded» относится к сверке — `hash_password` в
team.py оставлен на цикле сознательно как редкая аутентифицированная операция.

Refs #2665
2026-08-06 13:58:14 +05:00
bce5b0c02f fix(tradein/auth): слот проверки пароля освобождает работа, а не отмена запроса
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m3s
Колбэк висел на обёртке `run_in_executor`: у неё «готово» наступает и при
ОТМЕНЕ корутины, а подхваченная пулом задача при этом продолжает занимать
поток свои 282 мс. Значит отваливающийся клиент получал свежий слот на каждую
отмену и мог набивать очередь пула быстрее, чем та разгребается — темп bcrypt
по-прежнему держал бы пул, но очередь и память росли бы без границы.

Колбэк перевешен на future ПУЛА (`submit`), декремент возвращается в поток
цикла через `call_soon_threadsafe` — счётчик остаётся собственностью цикла и
живёт без лока. Тест на отмену ловит ровно эту разницу: он краснел на
предыдущей реализации.

Refs #2665
2026-08-06 13:25:11 +05:00
e8dda242c7 fix(tradein/auth): bcrypt вне событийного цикла + настоящий потолок темпа логинов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m5s
`verify_password` звалась синхронно внутри `async def login`. Замер в
прод-контейнере: bcrypt cost 12 (все живые хеши `$2b$12$`) = 282 мс медиана,
и всё это время единственный event loop backend'а стоял целиком — 3.6
проверки/с, стойло цикла до 836 мс. Форма входа публична с cutover'а #2571,
значит любой желающий клал ВЕСЬ трейд-ин, не зная ни одного пароля.

Та же блокировка была единственным настоящим потолком темпа: замедление из
#2571 (`await asyncio.sleep`) отпускает цикл, поэтому сотня соединений отспит
его параллельно — это латентность одного ответа, а не ограничение темпа.
Поэтому обе половины едут вместе и живут в ОДНОЙ функции
(`verify_password_bounded`): вынос без потолка ускорил бы перебор (замерено
16/с на дефолтном executor'е), потолок без выноса оставил бы отказ в
обслуживании. Состояние «вынесено, потолка нет» в коде невыразимо.

Потолок = размер пула проверок, дефолт 1 поток → те же ~3.5 проверки/с, что
случайно давала блокировка, но цикл свободен. Сверх очереди
(`login_password_verify_max_inflight`, 4) — сразу 429, без ожидания: ждущий
запрос держит соединение к БД, а в QueuePool их 5+10.

Потолок держится процессом, и это проверено, а не предположено: прод-бэкенд
запущен `uvicorn app.main:app` без `--workers`, а REDIS_URL в окружении
tradein-backend не задан вовсе (находка #2674) — потолок на Redis молча не
работал бы. Периметр (Caddy) не выбран: в стоковом caddy:2 модуля rate_limit
нет (`caddy list-modules` — 134 модуля, ни одного с rate_limit), это была бы
пересборка образа и правка инфраструктуры без теста.

Защиты #2571 не ослаблены: оба лимитера, счётчик неудач на имя и растущая
задержка остались как были; 429 при насыщении отдаётся ДО сверки, одинаково
для любого имени, и бюджет неудач по имени не тратит.

Тест меряет ТЕМП, а не латентность: 100 одновременных соединений, каждое со
своей парой (username, ip) — сценарий, в котором обе защиты #2571 не
срабатывают ни разу. Проверяется и потолок сверок/с, и то, что сторонний
запрос при этом обслуживается. Обе половины фальсифицированы: убрать вынос →
«худший сторонний запрос 1756мс», убрать потолок → «203 сверок/с при потолке
20/с».

Refs #2665
2026-08-06 13:16:19 +05:00