Merge pull request 'test(tradein/auth): флаки-замер «< 500 мс» заменён проверкой механизма — bcrypt обязан исполняться вне потока event loop' (#3350) from fix/3343-auth-latency-flaky into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-05 18:14:38 +00:00
commit 4441e6ccbd

View file

@ -35,6 +35,7 @@ import asyncio
import logging import logging
import os import os
import re import re
import threading
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
@ -722,9 +723,21 @@ def test_failed_login_events_reach_audit_with_counter_state(
# #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл # #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Порог ТЕМПА пробы (тик/с) для #3343. Не «сколько раз», а «как часто»: `elapsed`
# под нагрузкой растёт, поэтому абсолютное «>= 10 тиков» заблокированный цикл
# наберёт за 3-5с — прежний ассерт слабел ровно в том сценарии, ради которого
# написан. Замеры (macOS, 2026-09-05, три прогона каждый):
# пул (`asyncio.to_thread`, как в проде) — 65.5 / 65.6 / 66.3 тик/с;
# bcrypt прямо в `async def` (`return verify_password(...)` в
# `verify_password_bounded`) — 1.0 тик/с (6 тиков за 5.74с; заметь: старый
# порог «>= 10» на чуть более медленной машине эти 10 тиков добрал бы).
# Порог — геометрическая середина: sqrt(65.5 * 1.0) ≈ 8, то есть запас ×8 в обе
# стороны. Общий раннер отъедает пропускную способность, но не порядок величины.
MIN_PROBE_TICKS_PER_S = 8.0
async def test_login_flood_capped_by_rate_while_api_stays_responsive( async def test_login_flood_capped_by_rate_while_api_stays_responsive(
store: _Store, monkeypatch: pytest.MonkeyPatch store: _Store, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
"""Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при """Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при
этом остальной API продолжает отвечать. этом остальной API продолжает отвечать.
@ -763,14 +776,19 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
app = _build_test_app(store) app = _build_test_app(store)
attempts: list[float] = [] attempts: list[float] = []
verify_threads: set[int] = set()
def _slow_verify(plain: str, hashed: str) -> bool: def _slow_verify(plain: str, hashed: str) -> bool:
"""Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. """Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
Блокирующий `time.sleep`, а не `await` суть проблемы в том, что bcrypt Блокирующий `time.sleep`, а не `await` суть проблемы в том, что bcrypt
не отпускает поток; двойник с `await` проверял бы не то. не отпускает поток; двойник с `await` проверял бы не то.
Заодно записывает ПОТОК исполнения: это и есть механизм выноса (#3343) —
величина дискретная, от загрузки раннера не зависящая.
""" """
attempts.append(time.monotonic()) attempts.append(time.monotonic())
verify_threads.add(threading.get_ident())
time.sleep(verify_s) time.sleep(verify_s)
return False return False
@ -818,25 +836,60 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
codes = [c for lst in code_lists for c in lst] codes = [c for lst in code_lists for c in lst]
attempts_per_s = len(attempts) / elapsed attempts_per_s = len(attempts) / elapsed
probe_latencies.sort()
probe_ticks_per_s = len(probe_latencies) / elapsed
median_probe = probe_latencies[len(probe_latencies) // 2] if probe_latencies else float("nan")
worst_probe = probe_latencies[-1] if probe_latencies else float("nan")
# Печать ДО первого утверждения и МИМО перехвата: захваченный вывод зелёного
# теста pytest не показывает (CI гоняет `uv run pytest -q -rs`), а запас на
# общем раннере интересен ровно когда всё прошло. Выше assert'ов — намеренно:
# на сломанной системе иначе не осталось бы и диагностики.
with capsys.disabled():
print(
f"[#3343] проба {len(probe_latencies)} тиков за {elapsed:.2f}с = "
f"{probe_ticks_per_s:.1f} тик/с (порог {MIN_PROBE_TICKS_PER_S} тик/с), медиана "
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс; сверок "
f"{len(attempts)} = {attempts_per_s:.0f}/с при потолке {ceiling_per_s:.0f}/с"
)
# 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def` # 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def`
# сторонний запрос ждёт столько, сколько длится очередь сверок. # сторонний запрос ждёт столько, сколько длится очередь сверок.
# Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда # Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда
# остальные числа мерят не потолок, а паралич — их надо читать после # остальные числа мерят не потолок, а паралич — их надо читать после
# этого вердикта, а не вместо него. # этого вердикта, а не вместо него.
assert probe_latencies, "проба не сделала ни одного запроса" #
probe_latencies.sort() # Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял
assert probe_latencies[-1] < 0.5, ( # абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`).
f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" # На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей:
# 03.09 деплой встал на «худший сторонний запрос 544мс» ровно на том
# коммите, который часом раньше прошёл на незанятой машине. Порог, который
# краснеет от чужой параллельной сборки, не отличает поломку от нагрузки,
# а красный обязан значить «значение неверно», иначе его начинают
# переспрашивать. Обе замены переживают любую загрузку:
# - ПОТОК, в котором исполнилась сверка, — величина дискретная. Вернись
# bcrypt в `async def` — здесь окажется поток цикла, и никакой простой
# раннера этого не замаскирует и не подделает;
# - ТЕМП тиков пробы: цикл с bcrypt внутри стоит практически всю
# секунду флуда (сверки идут подряд по 50мс), и проба не успевает
# почти никогда; при выносе в пул она просыпается раз в 10мс.
# Сверяется именно ТЕМП (тик/с), а не число тиков: `elapsed` под
# нагрузкой растёт, и абсолютный порог «>= 10 тиков» заблокированный
# цикл наберёт за 3-5с — то есть абсолютный ассерт слабеет ровно в
# целевом сценарии. Порог MIN_PROBE_TICKS_PER_S — геометрическая
# середина между замерами, см. константу.
# Сами латентности остаются в сообщении как ДИАГНОСТИКА: числа полезны,
# когда тест красный, и не годятся в вердикт, пока машина общая.
assert verify_threads, "ни одной сверки пароля не состоялось — мерить нечего"
assert threading.get_ident() not in verify_threads, (
"сверка пароля исполнилась в потоке событийного цикла — bcrypt держит весь "
"API на всё время проверки (вынос в пул из #2665 отменён)"
) )
median_probe = probe_latencies[len(probe_latencies) // 2] assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят"
assert median_probe < verify_s, ( assert probe_ticks_per_s >= MIN_PROBE_TICKS_PER_S, (
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной " f"проба тикала {probe_ticks_per_s:.1f} раз/с при пороге {MIN_PROBE_TICKS_PER_S} "
f"сверки — цикл занят проверкой пароля, API стоит" f"({len(probe_latencies)} тиков за {elapsed:.2f}с, медиана "
) f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс) — цикл был занят"
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
assert len(probe_latencies) >= 10, (
f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
) )
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает