fix(tradein/auth): агрегированный отказ пишется на ERROR — иначе канала нет (#2715)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 3m8s

Бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py):
событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в
docker-логе, которая умирает с ротацией и редеплоем — то есть ровно тем следом,
на бесполезность которого жалуется пункт 1 issue. Прецедент цены известен
(#2674): монитор писал WARNING про протухшие куки, событий было ноль.

Спама не будет: запись не чаще раза в окно (1с), все группируются в один issue.
Получателей у проекта по-прежнему нет (#2673) — событие будет видно в интерфейсе
и никому не уйдёт; это сказано в docstring, а не подразумевается.

Уровень закреплён тестом: понижение до WARNING выключило бы канал молча.

Refs #2715
This commit is contained in:
bot-backend 2026-08-06 17:46:43 +05:00
parent 1610c01c6d
commit 8958a657cb
2 changed files with 16 additions and 4 deletions

View file

@ -219,10 +219,18 @@ def _saturated_429(ip: str) -> HTTPException:
дублируется. Первый отказ отчитывается сразу, а не в конце окна: одиночная
аномалия обязана быть видна, даже если продолжения не будет.
Уровень ERROR, а не WARNING, не косметика: бэкенд поднят с
`LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть
ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся
строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена
прецедента известна (#2674): монитор писал WARNING про протухшие куки — и
событий было ноль. Спама не будет: запись не чаще раза в окно, и все они
группируются в один issue (шаблон сообщения один).
Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей
(#2673), так что уведомление никому не уйдёт — след появляется в аудите и
в логе, а не в чьём-то телефоне. Проверить доставку поведенчески сейчас
нельзя, и утверждать её здесь было бы враньём.
(#2673), так что уведомление никому не уйдёт — событие будет видно в
интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески
сейчас не на чем, и утверждать её здесь было бы враньём.
`username=""` не заглушка: имя не пишем ПОТОМУ, что отказ случился до
того, как мы на него посмотрели. Записывай мы присланное, атакующий
@ -237,7 +245,7 @@ def _saturated_429(ip: str) -> HTTPException:
if now - _saturation_reported_at >= _SATURATION_REPORT_WINDOW_S:
rejected, _saturation_rejected = _saturation_rejected, 0
_saturation_reported_at = now
logger.warning(
logger.error(
"login rejected: password verify saturated — %d отказов с прошлой записи "
"(не чаще раза в %.0fс), последний ip=%s",
rejected,

View file

@ -1057,6 +1057,10 @@ def test_saturation_is_reported_once_per_window_and_lands_in_audit(
lines = [r for r in caplog.records if "saturated" in r.getMessage()]
assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет"
# ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR)
# (app/main.py), и только с ERROR запись становится событием GlitchTip.
# Понижение уровня выключило бы канал молча — прецедент #2674.
assert lines[0].levelno == logging.ERROR
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 1, saturated