fix(tradein/auth): bcrypt вне событийного цикла + настоящий потолок темпа логинов (#2665) #2712
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2712
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2665-bcrypt-offloop-and-throttle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
verify_passwordзвалась синхронно внутриasync def login. Замер в прод-контейнере (2026-08-06): bcrypt cost 12 (все 13 живых хешей —\$2b\$12\$) = 282 мс медиана, и всё это время единственный event loop стоял целиком: 3.6 проверки/с, стойло цикла до 836 мс. Форма входа публична с cutover'а #2571 → любой клал весь трейд-ин без единого валидного пароля.Та же блокировка была единственным настоящим потолком темпа. Замедление из #2571 потолком не является:
await asyncio.sleepотпускает цикл, сотня соединений отспит его параллельно.Обе половины в одной функции
verify_password_bounded— состояние «вынесено, потолка нет» невыразимо:login_password_verify_max_inflight, 4) — сразу 429, без ожидания: ждущий держит соединение к БД, а в QueuePool их 5+10.Почему в процессе, а не в Redis — проверено, а не предположено: прод-бэкенд
uvicorn app.main:appбез--workers;REDIS_URLв окружении tradein-backend не задан вовсе (printenv | grep -c ^REDIS_URL=→ 0, находка #2674). Потолок на Redis молча не работал бы.Почему не периметр (вариант 1 из issue): в стоковом
caddy:2модуля rate_limit нет —caddy list-modulesна проде выдаёт 134 модуля, ни одного сrate_limit. Это пересборка образа через xcaddy + правка инфраструктуры, и без теста. Вынесено владельцу отдельным предложением.#2571 не ослаблен: оба лимитера, счётчик неудач на имя и растущая задержка — как были. 429 при насыщении отдаётся ДО сверки, одинаково для любого имени, бюджет неудач по имени не тратит.
Test plan
test_login_flood_capped_by_rate_while_api_stays_responsive— 100 одновременных соединений, у каждого запроса СВОЯ пара (username, ip): сценарий, где обе защиты #2571 не срабатывают ни разу. Меряет ТЕМП (сверок/с за секунду непрерывного флуда), а не латентность одного ответа, и одновременно — что сторонний запрос обслуживается.худший сторонний запрос 1756мс — API встаёт под флудом входа; убрать потолок (asyncio.to_thread) →203 сверок/с при потолке 20/с — потолок темпа не работает.tests/test_password.py— вердикт не изменился, bcrypt считается в чужом потоке, избыток отклоняется а не копится, слоты возвращаются.test_search_api.py::test_search_cache_hit, воспроизводится на чистом origin/main в том же окружении (не из этого PR).Миграция не потребовалась (выделенный номер 222 не израсходован).
Refs #2665
Замер ДО (прод-контейнер tradein-backend, 2026-08-06)
Скрипт гонялся внутри контейнера, БД не трогал:
Строка INLINE — сегодняшний прод: 3.6 попытки/с ценой того, что цикл не обслуживает никого до 836 мс.
Строка THREADED — то, что получилось бы от «просто вынести в поток»: 16/с, перебор вчетверо быстрее.
Косвенное подтверждение снаружи кода:
POST /api/v1/auth/loginс несуществующим именем отвечает 401 за 362 мс (bcrypt против dummy-хеша доминирует) — это и есть та самая работа, что держала цикл.Фальсификация теста (обе половины по очереди сломаны в рабочей копии)
худший сторонний запрос 1756мс — API встаёт под флудом входаasyncio.to_thread)218 сверок/с при потолке 20/с (317 за 1.45с) — потолок темпа не работаетТретья фальсификация нашла реальный дефект в первой редакции этого же PR: колбэк освобождения слота висел на обёртке
run_in_executor, у которой «готово» наступает и при ОТМЕНЕ корутины — отменяющий клиент получал свежий слот на каждую отмену. Перевешен на future пула (второй коммит), тестtest_bounded_slot_freed_by_the_work_not_by_cancellationкраснеет на прежней реализации.Доступность хранилища, на которое опирается потолок
Потолок в памяти процесса — единственный, который РЕАЛЬНО работает в этом окружении: Redis не настроен (тот же класс дефекта, что #2674), в стоковом caddy:2 рейт-лимита нет вовсе.
Известный размен
Запросы, отбитые 429 при насыщении, НЕ попадают в
user_events(пароль не проверялся, это не попытка входа) — они видны только какlogin rejected: password verify saturated ip=...в логах приложения. Порог достигается только при флуде, но если захочется видеть насыщение в аудите — это отдельная правка.