Compare commits
No commits in common. "4441e6ccbdaedbf89991380970cb14e97b2a2aa6" and "65155e5ccd42637f4de107caebaf6d84b5ad6d7b" have entirely different histories.
4441e6ccbd
...
65155e5ccd
1 changed files with 13 additions and 66 deletions
|
|
@ -35,7 +35,6 @@ 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
|
||||||
|
|
@ -723,21 +722,9 @@ 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, capsys: pytest.CaptureFixture[str]
|
store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при
|
"""Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при
|
||||||
этом остальной API продолжает отвечать.
|
этом остальной API продолжает отвечать.
|
||||||
|
|
@ -776,19 +763,14 @@ 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
|
||||||
|
|
||||||
|
|
@ -836,60 +818,25 @@ 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, "проба не сделала ни одного запроса"
|
||||||
# Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял
|
probe_latencies.sort()
|
||||||
# абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`).
|
assert probe_latencies[-1] < 0.5, (
|
||||||
# На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей:
|
f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
|
||||||
# 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 отменён)"
|
|
||||||
)
|
)
|
||||||
assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят"
|
median_probe = probe_latencies[len(probe_latencies) // 2]
|
||||||
assert probe_ticks_per_s >= MIN_PROBE_TICKS_PER_S, (
|
assert median_probe < verify_s, (
|
||||||
f"проба тикала {probe_ticks_per_s:.1f} раз/с при пороге {MIN_PROBE_TICKS_PER_S} "
|
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной "
|
||||||
f"({len(probe_latencies)} тиков за {elapsed:.2f}с, медиана "
|
f"сверки — цикл занят проверкой пароля, API стоит"
|
||||||
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс) — цикл был занят"
|
)
|
||||||
|
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
|
||||||
|
assert len(probe_latencies) >= 10, (
|
||||||
|
f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
|
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue