Ревью показало, что «разрыва вынесено-без-потолка не существует» верно только
по коду: `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
Колбэк висел на обёртке `run_in_executor`: у неё «готово» наступает и при
ОТМЕНЕ корутины, а подхваченная пулом задача при этом продолжает занимать
поток свои 282 мс. Значит отваливающийся клиент получал свежий слот на каждую
отмену и мог набивать очередь пула быстрее, чем та разгребается — темп bcrypt
по-прежнему держал бы пул, но очередь и память росли бы без границы.
Колбэк перевешен на future ПУЛА (`submit`), декремент возвращается в поток
цикла через `call_soon_threadsafe` — счётчик остаётся собственностью цикла и
живёт без лока. Тест на отмену ловит ровно эту разницу: он краснел на
предыдущей реализации.
Refs #2665
`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