Merge remote-tracking branch 'refs/remotes/forgejo/main' into feat/tradein-payments-schema

This commit is contained in:
bot-backend 2026-08-06 19:00:24 +03:00
commit 31620c7559
27 changed files with 3674 additions and 104 deletions

View file

@ -58,6 +58,12 @@ async def list_accounts(
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
FROM user_events
-- Событие без имени не аккаунт (#2715: `login_verify_saturated`
-- пишется с пустым именем намеренно отказ случается ДО того, как
-- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её
-- last_seen_at момент атаки), а её кнопка в UI раскрывалась бы в
-- /audit/accounts/{username} с `min_length=1`, то есть в ошибку.
WHERE username <> ''
GROUP BY username
ORDER BY last_seen_at DESC
"""
@ -182,12 +188,16 @@ async def analytics_dashboard(
db.execute(
text(
"""
-- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они
-- честно входят в total_events, но не люди: count(DISTINCT) их
-- игнорирует по NULL, иначе первая же атака навсегда добавила бы
-- фантомного пользователя в счётчик уникальных.
SELECT count(*) AS total_events,
count(DISTINCT username) AS distinct_users,
count(DISTINCT NULLIF(username, '')) AS distinct_users,
count(*) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS events_last_24h,
count(DISTINCT username) FILTER (
count(DISTINCT NULLIF(username, '')) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS active_users_last_24h
FROM user_events
@ -204,7 +214,7 @@ async def analytics_dashboard(
"""
SELECT date_trunc('day', created_at)::date AS day,
count(*) AS events,
count(DISTINCT username) AS users
count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715)
FROM user_events
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
GROUP BY date_trunc('day', created_at)::date
@ -261,6 +271,7 @@ async def analytics_dashboard(
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
max(created_at) AS last_seen
FROM user_events
WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715)
GROUP BY username
ORDER BY events DESC
LIMIT 50

View file

@ -40,6 +40,10 @@ Security:
флуда получал 429 столько раз, сколько пытался. Ключ IP, поэтому защита
поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси,
общий адрес за NAT, ротация через ботнет) см. docstring той же функции.
Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом
пути оставалась бы единственная работа, время которой зависит от того,
существует ли имя, а bcrypt, который эту разницу ровняет, до него уже не
доходит. След инцидента агрегированный, `_saturated_429`.
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
@ -54,6 +58,7 @@ from __future__ import annotations
import asyncio
import logging
import secrets
import time
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -61,7 +66,12 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.password import PasswordVerifyOverloadedError, hash_password, verify_password_bounded
from app.core.password import (
PasswordVerifyOverloadedError,
hash_password,
verify_password_bounded,
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
@ -174,6 +184,115 @@ def _throttle_delay_s(fails_in_window: int) -> float:
return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16))
# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не
# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker
# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду
# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные
# логи ровно во время атаки — то есть в момент, когда они нужнее всего.
# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и
# крутить его нечем — меньше секунды возвращает исходную проблему, больше
# ухудшает разрешение по времени, не давая взамен ничего.
_SATURATION_REPORT_WINDOW_S = 1.0
# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи
# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик
# слотов в `app.core.password`: обе строчки исполняются в потоке событийного
# цикла и между чтением и записью нет `await`.
_saturation_rejected = 0
_saturation_reported_at: float | None = None
def _saturated_429(ip: str) -> HTTPException:
"""429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента.
Событие неудачного входа тут не пишется и бюджет неудач по имени не
тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а
трата бюджета означала бы, что насыщением можно заблокировать чужую учётку.
Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в
логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`).
Поэтому на окно приходится одна строка в лог И одно событие
`login_verify_saturated` в `user_events` с числом отказов, накопленных с
прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и
редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная
аномалия обязана быть видна, даже если продолжения не будет.
`since_prev_s` в payload НЕ дубль `created_at`, а единственный способ
прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ:
атака кончилась в 03:00, 900 отказов остались неотчитанными и во вторник
одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником
и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ
накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не
надо. `None` первая запись за жизнь процесса, сравнивать не с чем.
Уровень ERROR, а не WARNING, не косметика: бэкенд поднят с
`LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть
ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся
строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена
прецедента известна (#2674): монитор писал WARNING про протухшие куки — и
событий было ноль. Спама не будет: запись не чаще раза в окно, и все они
группируются в один issue (шаблон сообщения один).
Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей
(#2673), так что уведомление никому не уйдёт — событие будет видно в
интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески
сейчас не на чем, и утверждать её здесь было бы враньём.
`username=""` не заглушка: имя не пишем ПОТОМУ, что отказ случился до
того, как мы на него посмотрели. Записывай мы присланное, атакующий
наполнял бы аудит строками с любым именем на выбор. Пустое имя не аккаунт,
и списки аудита его отфильтровывают (`WHERE username <> ''` в
`app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке
аккаунтов и фантомом в `count(DISTINCT username)`. `ip` адрес последнего
отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов
много, и по одной записи их не восстановить (счётчик восстановит).
Потолок объёма: час непрерывной атаки это 3600 строк в `user_events`
(в таблице за всю её жизнь ~3.4 тысячи), сутки под 86 тысяч. Retention у
таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по
времени. То же давление уходит на квоту проекта в GlitchTip тот же
механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого
окно агрегации растёт с длительностью атаки (экспонента с потолком, как у
`_throttle_delay_s`), это следующий шаг, а не сегодняшний.
"""
global _saturation_rejected, _saturation_reported_at
_saturation_rejected += 1
now = time.monotonic()
since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at
if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S:
rejected, _saturation_rejected = _saturation_rejected, 0
_saturation_reported_at = now
logger.error(
"login rejected: password verify saturated — %d отказов, "
"с прошлой записи %s с, последний ip=%s",
rejected,
"" if since_prev is None else f"{since_prev:.1f}",
ip,
)
schedule_event(
event_type="login_verify_saturated",
username="",
ip=ip,
path="/api/v1/auth/login",
method="POST",
payload={
"rejected": rejected,
# Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0.
"since_prev_s": None if since_prev is None else round(since_prev, 1),
},
)
# Retry-After 1с — порядок времени одной сверки, не окно соседнего
# `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до
# сверки и потому ничего не сообщает о том, существует ли учётка.
return HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
)
async def _reject_invalid_credentials(
db: Session, username: str, ip: str, user_agent: str | None
) -> HTTPException:
@ -256,6 +375,16 @@ async def login(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый
# запрос не берёт соединение из пула и не делает SELECT по имени: под
# насыщением это была бы единственная работа на пути отказа, а значит и
# единственное, чьё время зависит от существования учётки — bcrypt, который
# эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение
# всё равно остаётся за `verify_password_bounded` ниже (тот же предикат,
# `except` под ним никуда не делся) — здесь только экономия похода в базу.
if verify_slots_saturated(ip):
raise _saturated_429(ip)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
@ -274,17 +403,11 @@ async def login(
password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip)
except PasswordVerifyOverloadedError:
# Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя —
# ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и
# случается ДО сверки, поэтому оракулом существования учётки не служит и
# бюджет неудач по имени не тратит (это не попытка входа: пароль не
# проверялся). Retry-After 1с — порядок времени одной проверки, не окно
# соседнего `_LOGIN_LIMITER`.
logger.warning("login rejected: password verify saturated ip=%s", ip)
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
) from None
# ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает,
# но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ —
# тот же самый и с той же аргументацией, что у предчека: один helper,
# чтобы две ветки не разъехались (одинаковость 429 — часть защиты).
raise _saturated_429(ip) from None
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.

View file

@ -123,6 +123,32 @@ def _per_key_slot_cap() -> int:
return max(1, settings.login_password_verify_max_inflight // 2)
def verify_slots_saturated(key: str) -> bool:
"""Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота.
Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт
стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё
равно брал соединение из пула и делал SELECT по имени тогда, когда система
уже перегружена. Хуже того, под насыщением эта выборка оставалась
ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа
для существующего и несуществующего имени, ниже по течению и до него не
доходит, так что разницу «строка найдена / не найдена» ничто не маскировало.
Предчек, а не решение: авторитетная проверка остаётся внутри
`verify_password_bounded` она зовёт ЭТУ ЖЕ функцию, так что разъехаться
двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта»
цел (слот здесь не резервируется и не отдаётся).
Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не
покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается
именно доля, и большинство отказов снова ходило бы в базу.
"""
return (
_verify_inflight >= settings.login_password_verify_max_inflight
or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap()
)
async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
"""`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665).
@ -193,9 +219,10 @@ async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
"""
global _verify_inflight
if _verify_inflight >= settings.login_password_verify_max_inflight:
raise PasswordVerifyOverloadedError
if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap():
# АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее
# (`verify_slots_saturated`, #2715), но решение принимается здесь и только
# здесь — предчек экономит поход в БД, а не заменяет этот отказ.
if verify_slots_saturated(key):
raise PasswordVerifyOverloadedError
loop = asyncio.get_running_loop()

View file

@ -92,6 +92,30 @@ BACKFILL (reduces recurrence):
(same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and
does not immediately re-split it.
MERGE JOURNAL the merge is REVERSIBLE (#2690, migration 230):
Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge:
the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity
carry-over, the ids of every child row whose FK moved, the full snapshots of every child row
a UNIQUE collision destroyed, plus the grounds which pass, which cluster-key VALUE fired,
whether the geo guard was on, and the keeperloser distance in metres.
This exists because the merge used to leave no restorable trace: losers were hard-deleted
with their children and the only record of «what went into what» was a log line, in a
container whose logs rotate faster than a day. A day after a run nobody could even NAME the
pairs, and the only rollback was restoring the whole database.
Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction restores the loser
rows, points the children back, re-inserts the destroyed children, and un-does the identity
carry-over on the keeper, reporting per record what it could and could not restore.
NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no
keeper rule and no guard. It only makes whatever the pass decides reversible which is the
precondition for revisiting those decisions at all (#2690, #1772).
distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
asymmetry merge allowed without a proximity check was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
IDEMPOTENCY:
Every UPDATE/DELETE keys off a temp mapping of (loserkeeper). On a clean table the
mapping is empty every statement touches 0 rows no-op. Re-running is safe.
@ -105,8 +129,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas
from __future__ import annotations
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
@ -260,7 +286,16 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
-- DIFFERENT house_fias_id provably different buildings the cluster key collapsed (canon
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
-- cluster) and for canon clusters where at most one side carries a fias.
SELECT id AS loser_id, keeper_id, norm_address
--
-- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690):
-- cluster_key records WHICH key value fired, distance_m how far apart the two rows were.
-- distance_m is computed even when the geo guard is OFF for this pass that is precisely
-- the case where nothing else records the distance, and #2690 had no way to ask
-- «how many merges happened at distances the guard would have blocked» from data.
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
THEN ST_DistanceSphere(loser_geom, keeper_geom)
END AS distance_m
FROM ranked
WHERE rn > 1
AND id <> keeper_id{geo_guard}
@ -289,6 +324,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id_fk = m.keeper_id
FROM _1772_dup_mapping m
WHERE l.house_id_fk = m.loser_id
RETURNING m.loser_id, l.id AS child_id
""",
),
(
@ -298,6 +334,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hph.house_id = m.loser_id
RETURNING m.loser_id, hph.id AS child_id
""",
),
(
@ -307,6 +344,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hr.house_id = m.loser_id
RETURNING m.loser_id, hr.id AS child_id
""",
),
(
@ -316,6 +354,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hrc.house_id = m.loser_id
RETURNING m.loser_id, hrc.id AS child_id
""",
),
(
@ -325,6 +364,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE ev.house_id = m.loser_id
RETURNING m.loser_id, ev.id AS child_id
""",
),
# ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ─────
@ -340,6 +380,7 @@ _STEPS: list[tuple[str, str]] = [
AND hs2.ext_source = hs.ext_source
AND hs2.ext_id = hs.ext_id
)
RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot
""",
),
(
@ -349,6 +390,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hs.house_id = m.loser_id
RETURNING m.loser_id, hs.id AS child_id
""",
),
# ── UNIQUE(normalized_address): delete colliding losers, re-point rest ─────
@ -363,6 +405,7 @@ _STEPS: list[tuple[str, str]] = [
WHERE haa2.house_id = m.keeper_id
AND haa2.normalized_address = haa.normalized_address
)
RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot
""",
),
(
@ -372,6 +415,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE haa.house_id = m.loser_id
RETURNING m.loser_id, haa.id AS child_id
""",
),
# ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ──
@ -393,6 +437,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -402,6 +447,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hpd.house_id = m.loser_id
RETURNING m.loser_id, hpd.id AS child_id
""",
),
# ── UNIQUE(house_id): one evaluation per keeper ───────────────────────────
@ -419,6 +465,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -428,6 +475,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hie.house_id = m.loser_id
RETURNING m.loser_id, hie.id AS child_id
""",
),
# ── UNIQUE(house_id, ext_item_id) ─────────────────────────────────────────
@ -445,6 +493,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -454,6 +503,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hs.house_id = m.loser_id
RETURNING m.loser_id, hs.id AS child_id
""",
),
# ── UNIQUE(house_id, audit_batch) ─────────────────────────────────────────
@ -471,6 +521,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -480,10 +531,98 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE ama.house_id = m.loser_id
RETURNING m.loser_id, ama.id AS child_id
""",
),
]
# ── MERGE JOURNAL (#2690) ─────────────────────────────────────────────────────
#
# Every child of houses(id) except `listings` references it through a column named house_id;
# listings uses house_id_fk. The undo function reads the column name back out of the journal
# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible.
_FK_COLUMN = {"listings": "house_id_fk"}
# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the
# set cannot drift away from them. Compared against pg_catalog before every merge — see
# _assert_all_fk_children_handled.
_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset(
(tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS}
)
# Live FK children of houses(id), read from the catalog rather than trusted from a comment.
_FK_CHILDREN_SQL = text(
"""
SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table,
a.attname AS fk_column
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.confrelid = CAST('houses' AS regclass)
AND c.contype = 'f'
"""
)
# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row
# is the row as it stood, and keeper_before precedes the identity carry-over.
_JOURNAL_INSERT_SQL = text(
"""
INSERT INTO house_merge_log (
batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m,
norm_address, loser_id, keeper_id, loser_row, keeper_before
)
SELECT
CAST(:batch_id AS uuid),
CAST(:run_id AS bigint),
CAST(:initiator AS text),
CAST(:merge_pass AS text),
m.cluster_key,
CAST(:geo_guard AS boolean),
m.distance_m,
m.norm_address,
m.loser_id,
m.keeper_id,
to_jsonb(l.*),
to_jsonb(k.*)
FROM _1772_dup_mapping m
JOIN houses l ON l.id = m.loser_id
JOIN houses k ON k.id = m.keeper_id
"""
)
# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which
# were destroyed by a UNIQUE collision.
_JOURNAL_CHILDREN_SQL = text(
"""
UPDATE house_merge_log
SET children_repointed = CAST(:children_repointed AS jsonb),
children_deleted = CAST(:children_deleted AS jsonb)
WHERE batch_id = CAST(:batch_id AS uuid)
AND loser_id = CAST(:loser_id AS bigint)
"""
)
def _assert_all_fk_children_handled(db: Session) -> None:
"""Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle.
This is what makes the journal's promise true rather than merely documented. An unhandled
child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses`
would destroy its rows silently no re-point step touches them, no RETURNING records them,
and the journal would claim a complete snapshot it does not have. Migration 133 already
broke on prod for exactly this (a missed child); there the failure was loud. Here it would
be silent, which is worse. Aborting the transaction costs one skipped merge cycle.
"""
live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()}
unhandled = live - _HANDLED_CHILDREN
if unhandled:
raise RuntimeError(
"merge_duplicate_houses: houses(id) has FK children the merge does not handle: "
f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. "
"Add a re-point step to _STEPS (and its RETURNING) before merging again."
)
# Delete the loser houses — all FK children are re-pointed or CASCADE by now.
_DELETE_LOSERS_SQL = text(
"""
@ -607,14 +746,20 @@ def _run_merge_pass(
*,
build_sql: Any,
pass_label: str,
geo_guard: bool,
batch_id: str,
run_id: int | None,
initiator: str,
result: DedupMergeResult,
) -> None:
"""Run ONE merge pass (fias- or canon-key) inside the caller's open transaction.
Builds a fresh loserkeeper mapping for this pass's cluster key, re-points every FK child
(UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and
backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back the
caller owns the single transaction wrapping both passes.
Builds a fresh loserkeeper mapping for this pass's cluster key, writes the MERGE JOURNAL
(#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto
the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto
`result`. NEVER commits/rolls back the caller owns the single transaction wrapping both
passes, which is also what makes the journal atomic with the merge: there is no ordering in
which the rows vanish but the journal entry does not land (and dry_run rolls back both).
"""
# Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table
# explicitly — the second pass must rebuild the same-named table within the one transaction.
@ -623,8 +768,8 @@ def _run_merge_pass(
mapping = db.execute(
text(
"SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping "
"ORDER BY keeper_id, loser_id"
"SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m "
"FROM _1772_dup_mapping ORDER BY keeper_id, loser_id"
)
).all()
if not mapping:
@ -634,32 +779,73 @@ def _run_merge_pass(
result.losers_deleted += len(mapping)
result.clusters_merged += len({row.keeper_id for row in mapping})
# Audit log: every loser→keeper move with its address, for traceability.
# JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them.
db.execute(
_JOURNAL_INSERT_SQL,
{
"batch_id": batch_id,
"run_id": run_id,
"initiator": initiator,
"merge_pass": pass_label,
"geo_guard": geo_guard,
},
)
# Container logs rotate faster than a day (#2690), so this line is a convenience, not the
# record — house_merge_log is. Distance is logged too: it is the one number that says
# whether a merge would have survived the geo guard.
for row in mapping:
logger.info(
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r",
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r "
"distance_m=%s batch=%s",
pass_label,
row.loser_id,
row.keeper_id,
row.norm_address,
"n/a" if row.distance_m is None else f"{row.distance_m:.0f}",
batch_id,
)
# Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the
# rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is
# left of them).
repointed: dict[int, dict[str, list[int]]] = {}
deleted: dict[int, dict[str, list[Any]]] = {}
for label, sql in _STEPS:
res = db.execute(text(sql))
rowcount = res.rowcount or 0
if label == "listings":
result.listings_repointed += rowcount
elif label.endswith("(collision-delete)") or label.endswith("(dedup)"):
rows = db.execute(text(sql)).all()
rowcount = len(rows)
table = label.split("(")[0]
if label.endswith("(collision-delete)") or label.endswith("(dedup)"):
result.children_deleted += rowcount
elif label.endswith("(re-point)") or label in (
"house_placement_history",
"house_reviews",
"house_reliability_checks",
"external_valuations",
):
result.children_repointed += rowcount
for r in rows:
deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot)
else:
key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}"
for r in rows:
repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id)
if label == "listings":
result.listings_repointed += rowcount
else:
result.children_repointed += rowcount
logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount)
# JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1.
touched = sorted(set(repointed) | set(deleted))
if touched:
db.execute(
_JOURNAL_CHILDREN_SQL,
[
{
"batch_id": batch_id,
"loser_id": loser_id,
"children_repointed": json.dumps(repointed.get(loser_id, {})),
"children_deleted": json.dumps(deleted.get(loser_id, {}), default=str),
}
for loser_id in touched
],
)
# Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill.
db.execute(_CARRY_OVER_IDENTITY_SQL)
db.execute(_DELETE_LOSERS_SQL)
@ -667,7 +853,13 @@ def _run_merge_pass(
db.execute(_BACKFILL_ALIASES_SQL)
def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
def merge_duplicate_houses(
db: Session,
*,
dry_run: bool = False,
run_id: int | None = None,
initiator: str = "manual",
) -> dict[str, int]:
"""Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper.
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
@ -680,16 +872,41 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
empty mapping in each pass every statement is a 0-row no-op.
Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a
merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line
and stored on every journal row of this call.
Returns the counter dict (DedupMergeResult.to_counters()).
"""
start = time.monotonic()
result = DedupMergeResult(dry_run=dry_run)
batch_id = str(uuid.uuid4())
try:
# Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled.
_assert_all_fk_children_handled(db)
# Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity).
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result)
_run_merge_pass(
db,
build_sql=_BUILD_MAPPING_SQL_FIAS,
pass_label="fias",
geo_guard=False,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
result=result,
)
# Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard.
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result)
_run_merge_pass(
db,
build_sql=_BUILD_MAPPING_SQL,
pass_label="canon",
geo_guard=True,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
result=result,
)
if result.losers_deleted == 0:
# Clean table — both passes empty. Roll back (we only opened temp tables).
@ -716,12 +933,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
db.commit()
logger.info(
"merge_duplicate_houses: COMMITTED clusters=%d losers=%d "
"listings_repointed=%d children_deleted=%d children_repointed=%d",
"listings_repointed=%d children_deleted=%d children_repointed=%d "
"batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))",
result.clusters_merged,
result.losers_deleted,
result.listings_repointed,
result.children_deleted,
result.children_repointed,
batch_id,
batch_id,
)
except Exception:
logger.exception("merge_duplicate_houses: FAILED — rolling back")
@ -761,7 +981,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
counters = merge_duplicate_houses(db, dry_run=dry_run)
counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule")
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s",

View file

@ -0,0 +1,17 @@
"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C).
Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты
(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/
конструктора. Причина параллельный PR-B вводит эти поля в `config.py`,
а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина)
делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо)
§3/§9 для полной схемы разбивки.
- `token.py` подпись `Token` запросов + проверка подписи нотификаций.
- `receipt.py` сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги.
- `tbank_client.py` httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`.
Docs: https://developer.tbank.ru/eacq/intro
"""
from __future__ import annotations

View file

@ -0,0 +1,143 @@
"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга.
Продукт продаёт УСЛУГУ (не товар) везде фиксированы `PaymentObject="service"`
и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу,
без предоплат/кредита/частичных расчётов).
Схема (`Receipt` в `Init`, ФФД 1.05) источник, снят живым запросом
2026-08-06: https://developer.tbank.ru/eacq/api/init
- `Email` ИЛИ `Phone` обязательно хотя бы одно (перекрёстный required).
- `Taxation` обязателен: `osn|usn_income|usn_income_outcome|esn|patent`.
- `Items[].Name` <=128 символов, обязателен.
- `Items[].Price`/`Quantity`/`Amount` числа, В КОПЕЙКАХ; `Amount` это
произведение `Price * Quantity` (дословно из API-reference).
- `Items[].Tax` ставка НДС. Актуальный список 2026 (Init API reference):
`none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`.
`vat20`/`vat120` В СПИСКЕ НЕТ сняты, не использовать (см. recon §6/§11
в `mera-tbank-acquiring-recon.md`, корень репо).
ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые
вложенные `dict`/`list` из подписи) это архитектурно гарантировано самой
функцией `token.sign`, а не соглашением здесь.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
TaxRate = Literal[
"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"
]
Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"]
_ALLOWED_TAX_RATES: frozenset[str] = frozenset(
{"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"}
)
_ALLOWED_TAXATION: frozenset[str] = frozenset(
{"osn", "usn_income", "usn_income_outcome", "esn", "patent"}
)
_MAX_ITEM_NAME_LEN = 128
_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference)
class ReceiptBuildError(ValueError):
"""Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка."""
@dataclass(frozen=True, slots=True)
class ReceiptItem:
"""Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки."""
name: str
price_kopecks: int
quantity: int = 1
tax: TaxRate = "none"
@property
def amount_kopecks(self) -> int:
"""Items[].Amount = Price * Quantity (дословно из API reference)."""
return self.price_kopecks * self.quantity
def to_payload(self) -> dict[str, Any]:
if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN:
raise ReceiptBuildError(
f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, "
f"получено {len(self.name)}"
)
if self.price_kopecks <= 0:
raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)")
if self.quantity <= 0:
raise ReceiptBuildError("Items[].Quantity должен быть > 0")
if self.tax not in _ALLOWED_TAX_RATES:
raise ReceiptBuildError(
f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка "
f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются"
)
return {
"Name": self.name,
"Price": self.price_kopecks,
"Quantity": self.quantity,
"Amount": self.amount_kopecks,
"Tax": self.tax,
"PaymentMethod": "full_payment",
"PaymentObject": "service",
}
def build_receipt(
*,
items: list[ReceiptItem],
taxation: Taxation,
email: str | None = None,
phone: str | None = None,
) -> dict[str, Any]:
"""Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции).
Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется
`Receipt` строится независимо от `Init`-payload заказа. Сверка на
вызывающей стороне (`service.py`, следующий PR) через
`receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест
`test_receipt_total_matches_order_amount_invariant` в
`tests/test_payments_receipt.py`, который проверяет именно эту сверку.
"""
if not items:
raise ReceiptBuildError("Receipt.Items не может быть пустым")
if len(items) > _MAX_ITEMS:
raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций")
if taxation not in _ALLOWED_TAXATION:
raise ReceiptBuildError(
f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})"
)
email_norm = (email or "").strip() or None
phone_norm = (phone or "").strip() or None
if not email_norm and not phone_norm:
raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)")
payload: dict[str, Any] = {
"Taxation": taxation,
"Items": [item.to_payload() for item in items],
}
if email_norm:
payload["Email"] = email_norm
if phone_norm:
payload["Phone"] = phone_norm
return payload
def receipt_total_kopecks(receipt: dict[str, Any]) -> int:
"""Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`."""
items = receipt.get("Items")
if not isinstance(items, list):
return 0
total = 0
for item in items:
if isinstance(item, dict):
amount = item.get("Amount")
if isinstance(amount, int):
total += amount
return total

View file

@ -0,0 +1,249 @@
"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel).
Стиль и обработка ошибок по образцу
`app.services.tgbot.client.TelegramClient`: единственные нужные методы,
не тянем отдельный SDK ради пяти HTTP-вызовов.
Модуль НЕ импортирует `app.core.config` все параметры (`terminal_key`,
`password`, `base_url`) передаются в конструктор явно аргументами.
Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`):
параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D.
Docs: https://developer.tbank.ru/eacq/api
Ретраи:
- Сетевые ошибки (timeout/connect) и HTTP 5xx экспоненциальный backoff,
capped на `_MAX_BACKOFF_S`.
- Любая 4xx НЕ ретраится (запрос некорректен / права не те повтор
транспортного вызова не поможет), сразу `TBankApiError`.
- Бизнес-отказ (HTTP 200, но `Success: false` в теле) тоже НЕ
ретраится: это содержательный ответ банка, а не сбой транспорта.
БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*`
логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details`
из ответа банка.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from app.services.payments.token import sign
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S = 15.0
_MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 3
DEFAULT_BASE_URL = "https://securepay.tinkoff.ru"
class TBankApiError(Exception):
"""T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`)."""
def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None:
self.method = method
self.error_code = error_code
self.message = message
self.details = details
text = f"T-Bank API {method} failed: [{error_code}] {message}"
if details:
text += f"{details}"
super().__init__(text)
def _error_from_body(response: httpx.Response) -> tuple[str, str, str]:
"""Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус."""
try:
data = response.json()
except ValueError:
return str(response.status_code), (response.text or "")[:200], ""
if not isinstance(data, dict):
return str(response.status_code), str(data)[:200], ""
error_code = str(data.get("ErrorCode", response.status_code))
message = str(data.get("Message", ""))
details = str(data.get("Details", ""))
return error_code, message, details
class TBankClient:
"""Клиент Т-Банк эквайринга на `httpx.AsyncClient`.
Каждый вызов отдельное короткоживущее соединение (без общего
connection-pool между вызовами; частота вызовов в checkout-потоке
низкая, держать долгоживущий клиент не нужно тот же паттерн, что
`TelegramClient`).
"""
def __init__(
self,
*,
terminal_key: str,
password: str,
base_url: str = DEFAULT_BASE_URL,
timeout: float = _DEFAULT_TIMEOUT_S,
) -> None:
self._terminal_key = terminal_key
self._password = password
self._base = f"{base_url.rstrip('/')}/v2"
self._timeout = timeout
def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит."""
body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload}
body["Token"] = sign(body, self._password)
return body
async def _request(
self,
method: str,
payload: dict[str, Any],
*,
max_retries: int = _DEFAULT_MAX_RETRIES,
) -> dict[str, Any]:
"""POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу."""
body = self._signed_payload(payload)
url = f"{self._base}/{method}"
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(url, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
if attempt > max_retries:
logger.error(
"tbank client: %s — network error после %d попыток: %s",
method,
attempt,
exc,
)
raise TBankApiError(method, "network_error", str(exc)) from exc
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tbank client: %s — network error (попытка %d/%d) — retry через %.0fs",
method,
attempt,
max_retries,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code >= 500:
if attempt > max_retries:
error_code, message, details = _error_from_body(response)
logger.error(
"tbank client: %s — HTTP %d после %d попыток, сдаёмся",
method,
response.status_code,
attempt,
)
raise TBankApiError(method, error_code, message, details)
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
method,
response.status_code,
attempt,
max_retries,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code >= 400:
# 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет.
error_code, message, details = _error_from_body(response)
raise TBankApiError(method, error_code, message, details)
try:
data = response.json()
except ValueError as exc:
raise TBankApiError(method, "invalid_json", str(exc)) from exc
if not isinstance(data, dict):
raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект")
if not data.get("Success"):
error_code = str(data.get("ErrorCode", response.status_code))
message = str(data.get("Message", ""))
details = str(data.get("Details", ""))
raise TBankApiError(method, error_code, message, details)
return data
async def init_payment(
self,
*,
order_id: str,
amount_kopecks: int,
description: str = "",
notification_url: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
receipt: dict[str, Any] | None = None,
pay_type: str | None = None,
data: dict[str, str] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`."""
payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks}
if description:
payload["Description"] = description
if notification_url:
payload["NotificationURL"] = notification_url
if success_url:
payload["SuccessURL"] = success_url
if fail_url:
payload["FailURL"] = fail_url
if receipt:
payload["Receipt"] = receipt
if pay_type:
payload["PayType"] = pay_type
if data:
payload["DATA"] = data
return await self._request("Init", payload)
async def get_state(self, *, payment_id: str) -> dict[str, Any]:
"""`POST /v2/GetState` — статус платежа по `PaymentId`."""
return await self._request("GetState", {"PaymentId": payment_id})
async def check_order(self, *, order_id: str) -> dict[str, Any]:
"""`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции)."""
return await self._request("CheckOrder", {"OrderId": order_id})
async def confirm(
self,
*,
payment_id: str,
amount_kopecks: int | None = None,
receipt: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`)."""
payload: dict[str, Any] = {"PaymentId": payment_id}
if amount_kopecks is not None:
payload["Amount"] = amount_kopecks
if receipt:
payload["Receipt"] = receipt
return await self._request("Confirm", payload)
async def cancel(
self,
*,
payment_id: str,
amount_kopecks: int | None = None,
receipt: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан)."""
payload: dict[str, Any] = {"PaymentId": payment_id}
if amount_kopecks is not None:
payload["Amount"] = amount_kopecks
if receipt:
payload["Receipt"] = receipt
return await self._request("Cancel", payload)

View file

@ -0,0 +1,98 @@
"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций.
Docs (проверено живым запросом к doc-порталу, 2026-08-06):
- https://developer.tbank.ru/eacq/intro/developer/token формирование Token.
- https://developer.tbank.ru/eacq/intro/developer/notification
(раздел «Проверить токен уведомлений») тот же алгоритм для входящих
нотификаций.
Алгоритм (идентичен для исходящего запроса и для проверки нотификации):
1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем
`None`, исключаем значения-`dict`/`list` (документация формулирует это
как «кроме параметра Token и вложенных объектов (Data, Receipt)»
здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые
вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items`
или `Shops`, отсекаются одинаково, потому что все они не примитивы).
2. `bool` `"true"`/`"false"` (нижний регистр); `int`/`float` строка без
экспоненциальной записи; `str` как есть.
3. Добавляем пару `Password: <пароль_терминала>`.
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
doc-портала оба подтверждены живым запросом, не выдуманы.
"""
from __future__ import annotations
import hashlib
import hmac
from typing import Any
_EXCLUDED_KEYS = frozenset({"Token"})
def _stringify_value(value: bool | int | float | str) -> str:
"""Приводит плоское значение к строке по правилам Т-Банка.
`bool` проверяем ДО `int`: в Python `bool` подкласс `int`
(`isinstance(True, int) is True`), поэтому порядок веток важен
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# `format(..., "f")` — фиксированная нотация, Python никогда не
# добавляет экспоненту при presentation type 'f' (в отличие от
# str()/repr(), которые для очень больших/малых float дают "1e+21").
text = format(value, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
return str(value)
def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]:
"""Плоские поля payload, готовые к конкатенации: без Token/None/dict/list."""
result: dict[str, str] = {}
for key, value in payload.items():
if key in _EXCLUDED_KEYS or value is None:
continue
if isinstance(value, dict | list):
continue
result[key] = _stringify_value(value)
return result
def sign(payload: dict[str, Any], password: str) -> str:
"""Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...).
`payload` тело запроса ДО добавления поля `Token` (поле `Password`
самому передавать не нужно функция добавляет его сама и удаляет
участие любых вложенных объектов автоматически).
"""
fields = _flatten_signable_fields(payload)
fields["Password"] = password
raw = "".join(fields[key] for key in sorted(fields))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def verify_notification_token(payload: dict[str, Any], password: str) -> bool:
"""Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`.
`payload` полное тело нотификации, включая присланный `Token` (сам
алгоритм сборки исключает ключ `Token` из подписи см. `_EXCLUDED_KEYS`).
Возвращает `False`, если в payload нет строкового непустого `Token`
(нечего сравнивать) вызывающая сторона обязана трактовать это как
отказ в обработке нотификации, а не как «пропустить проверку».
"""
received_token = payload.get("Token")
if not isinstance(received_token, str) or not received_token:
return False
expected_token = sign(payload, password)
return hmac.compare_digest(expected_token, received_token)

View file

@ -77,6 +77,20 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
прогона.
Два тракта два диагноза (#2723):
- ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет
consecutive_fails / enabled / exit_ip. Такт каждый прогон healthcheck (30 мин).
- браузерная проба (`_run_browser_probe` сайдкар camoufox с ЭТИМ прокси
навигация) отвечает на «через узел работает браузерный тракт» и владеет
browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228).
Такт свой, редкий (BROWSER_PROBE_MINUTES) она стоит запуска camoufox.
Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе
дешёвая проба каждые 30 минут стирает вердикт дорогого тракта узел, мёртвый для
браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел
(он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686.
«Непригоден для браузера» это НЕ исключение из пула: acquire() лишь отдаёт такой
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@ -90,9 +104,13 @@ import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings as _settings
logger = logging.getLogger(__name__)
__all__ = [
"BROWSER_PROBE_MINUTES",
"BROWSER_UNFIT_THRESHOLD",
"DISABLED_RECHECK_MINUTES",
"DISABLE_THRESHOLD",
"MAX_CONSECUTIVE_FAILS",
@ -105,6 +123,7 @@ __all__ = [
"acquire",
"clear_source_bans",
"mark_banned",
"mark_browser_health",
"mark_health",
"reap_stale_leases",
"release",
@ -155,6 +174,27 @@ SOURCE_BAN_PURGE_DAYS = 7
_HEALTH_PROBE_URL = "https://api.ipify.org"
_HEALTH_PROBE_TIMEOUT_S = 10.0
# ── браузерная проба узла (#2723) ────────────────────────────────────────────
# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026):
# - одна браузерная проба = 8.3с и один запуск camoufox;
# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час
# (≈1000 и ≈190 в сутки);
# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки.
# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки,
# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт
# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена,
# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно
# ловится в разы раньше, чем сейчас (не ловится вовсе).
BROWSER_PROBE_MINUTES = 360
# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в
# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция
# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте
# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck
# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале
# намеренно не обновляется (см. mark_browser_health).
BROWSER_UNFIT_THRESHOLD = 2
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
@ -213,7 +253,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT id, url, kind, rotate_url
SELECT id, url, kind, rotate_url, browser_unfit_since
FROM scrape_proxies
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
@ -226,7 +266,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
AND b.source = :provider
AND b.banned_until > now()
)
ORDER BY last_ok_at NULLS LAST, id
-- browser_unfit последним (#2723): узел, живой для HTTP, но не для
-- браузера, из пула НЕ исключается только уходит в конец очереди.
ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -247,7 +289,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since
FROM scrape_proxies AS sp
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
@ -283,7 +325,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
)
)
ORDER BY sp.last_ok_at NULLS LAST, sp.id
-- см. ORDER BY основного запроса (#2723)
ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -323,6 +366,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
)
if row["browser_unfit_since"] is not None:
# Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит
# пригодных свободных не осталось. Голодание хуже работы через плохой узел
# (та же политика, что у защиты последнего узла в mark_banned), но молчать об
# этом нельзя: для браузерного источника это заведомо обречённый прогон.
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s "
"(жив для HTTP, браузерный тракт через него не работает). Выдан потому, "
"что пригодных свободных узлов нет — пул надо пополнять (#2638).",
proxy_id,
provider,
row["browser_unfit_since"],
)
return ProxyLease(
id=proxy_id,
url=str(row["url"]),
@ -480,6 +536,145 @@ def mark_health(
)
def mark_browser_health(
db: Session,
proxy_id: int,
ok: bool,
*,
fail_kind: str | None = None,
detail: str = "",
) -> str:
"""Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков.
ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от
«у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того
же. mark_health отвечает на «узел жив вообще» и владеет
consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает
браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/
browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное:
- успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы
(через consecutive_fails=0) узел, мёртвый для браузера, выходил из карантина
каждые 30 минут и снова забирал прогон;
- провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел:
он жив, просто не для этого тракта.
ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см.
scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и
"page" (площадка отдала пустое) узлу не принадлежат засчитывать их значило бы
пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть
повторить #2686 ещё раз и уже с последствиями для всего пула.
ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН
(streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом
провале поле остаётся старым следующий же прогон healthcheck (~30 мин) повторит
пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы
полный BROWSER_PROBE_MINUTES.
Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что
помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored"
(провал не принадлежит узлу).
"""
if ok:
row = (
db.execute(
text(
"""
UPDATE scrape_proxies AS sp
SET browser_fail_streak = 0,
browser_unfit_since = NULL,
browser_check_at = now(),
updated_at = now()
-- prev pre-image строки: RETURNING отдаёт УЖЕ обновлённые
-- значения (browser_unfit_since там всегда NULL), а нам нужно
-- знать, была ли это реанимация непригодного узла.
FROM (
SELECT id, browser_unfit_since
FROM scrape_proxies
WHERE id = CAST(:id AS bigint)
) AS prev
WHERE sp.id = prev.id
RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit
"""
),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
was_unfit = bool(row["was_unfit"]) if row is not None else False
logger.info(
"proxy_pool: browser probe OK id=%d (%s)%s",
proxy_id,
detail,
" — узел снова пригоден для браузера" if was_unfit else "",
)
return "refit" if was_unfit else "ok"
if fail_kind != "proxy":
logger.warning(
"proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу "
"(fail_kind=%s): %s — browser_fail_streak не трогаем",
proxy_id,
fail_kind,
detail,
)
return "ignored"
row = (
db.execute(
text(
"""
UPDATE scrape_proxies
SET browser_fail_streak = browser_fail_streak + 1,
browser_unfit_since = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
AND browser_unfit_since IS NULL
THEN now() ELSE browser_unfit_since
END,
browser_check_at = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
THEN now() ELSE browser_check_at
END,
updated_at = now()
WHERE id = CAST(:id AS bigint)
RETURNING browser_fail_streak, browser_unfit_since
"""
),
{"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
if row is None:
logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id)
return "ignored"
streak = int(row["browser_fail_streak"])
if streak >= BROWSER_UNFIT_THRESHOLD:
logger.warning(
"proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для "
"обычного HTTP, но браузерный тракт через него не работает: %s. Узел "
"ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() "
"теперь отдаёт его последним (#2723).",
proxy_id,
streak,
detail,
)
return "unfit"
logger.warning(
"proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не "
"достигнут — перепроверим на следующем прогоне): %s",
proxy_id,
streak,
BROWSER_UNFIT_THRESHOLD,
detail,
)
return "fail"
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
@ -645,8 +840,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
current = (
db.execute(
text(
"SELECT enabled, disabled_reason FROM scrape_proxies "
"WHERE id = CAST(:id AS bigint)"
"SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)"
),
{"id": proxy_id},
)
@ -775,6 +969,29 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
return False, None, None, "other"
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
healthcheck ipify-часть уже отработала и её результат записан. Диагностика не
имеет права ломать то, что диагностирует.
"""
from scraper_kit.browser_fetcher import probe_proxy_via_browser
try:
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind
)
except Exception:
logger.warning(
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
proxy_id,
exc_info=True,
)
return "ignored"
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
def _mask(url: str) -> str:
"""Скрыть пароль в proxy-url для логов (scheme://user:***@host)."""
if "@" not in url or "//" not in url:
@ -808,9 +1025,18 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
В конце purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
(см. комментарий у самого DELETE: отложенность это и есть сброс ban_count).
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
никогда не смешивается с consecutive_fails/enabled. Гейт settings.
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
измеряла бы то, чем никто не пользуется.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed, revived, bans_purged}.
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
browser_unfit, browser_refit}.
"""
reaped = reap_stale_leases(db)
@ -818,7 +1044,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
SELECT id, url, kind, enabled, disabled_reason
SELECT id, url, kind, enabled, disabled_reason,
(browser_check_at IS NULL
OR browser_check_at < now() - make_interval(
mins => CAST(:browser_probe_minutes AS integer)
)) AS browser_probe_due
FROM scrape_proxies
WHERE enabled
OR last_check_at IS NULL
@ -828,7 +1058,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ORDER BY id
"""
),
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
{
"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES,
"browser_probe_minutes": BROWSER_PROBE_MINUTES,
},
)
.mappings()
.all()
@ -838,6 +1071,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ok_count = 0
failed = 0
revived = 0
browser_checked = 0
browser_ok = 0
browser_unfit = 0
browser_refit = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
@ -860,6 +1097,22 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
else:
failed += 1
# Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода
# мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая
# трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер
# ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
# не тот узел», из-за которого #2723 и появилась.
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
browser_checked += 1
if outcome in ("ok", "refit"):
browser_ok += 1
if outcome == "refit":
browser_refit += 1
elif outcome == "unfit":
browser_unfit += 1
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
@ -882,13 +1135,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
"bans_purged=%d",
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
reaped,
checked,
ok_count,
failed,
revived,
purged,
browser_checked,
browser_ok,
browser_unfit,
browser_refit,
)
return {
"reaped": reaped,
@ -897,4 +1154,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"failed": failed,
"revived": revived,
"bans_purged": purged,
# Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed:
# схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль.
"browser_checked": browser_checked,
"browser_ok": browser_ok,
"browser_unfit": browser_unfit,
"browser_refit": browser_refit,
}

View file

@ -578,6 +578,7 @@ def mark_backfill_finished(
*,
source: str,
aborted_by_blocks: bool = False,
fail_hint: str | None = None,
) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
@ -601,11 +602,19 @@ def mark_backfill_finished(
`gone` (404 у avito) считается результатом наравне с `enriched`: прогон,
который подтвердил снятие объявлений, работу сделал.
`fail_hint` самая частая причина отказа этого прогона (задача считает её сама,
см. avito_detail_backfill._failure_signature). Дописывается в текст статуса,
потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
контейнера на этот вопрос отвечать не могут они исчезают при пересоздании
контейнера, то есть на первом же деплое после ночного прогона.
"""
attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0)
blocked = int(counters.get("blocked") or 0)
produced = enriched + int(counters.get("gone") or 0)
hint = f"; причина: {fail_hint}" if fail_hint else ""
if attempted == 0:
mark_done(db, run_id, counters)
@ -614,7 +623,7 @@ def mark_backfill_finished(
if blocked and (aborted_by_blocks or produced == 0):
reason = (
f"backfill-honest-status: {source} остановлен блоками источника — "
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток (#2674)"
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_banned(db, run_id, reason, counters)
@ -624,7 +633,7 @@ def mark_backfill_finished(
reason = (
f"backfill-honest-status: {source} без результата — 0 обогащено из "
f"{attempted} попыток (failed={counters.get('failed', 0)}, "
f"blocked={blocked}) (#2674)"
f"blocked={blocked}){hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_failed(db, run_id, reason, counters)

View file

@ -13,6 +13,13 @@ session path as the detail-phase of `run_avito_city_sweep`
rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
блоками прогона 'banned' (#2674, runs.mark_backfill_finished): работу он не
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны
3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и
выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси.
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
отказа пишется в текст статуса прогона (_failure_signature) иначе она живёт
только в логах контейнера, а те исчезают на первом же деплое.
"""
from __future__ import annotations
@ -20,7 +27,9 @@ from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from collections import Counter
from dataclasses import dataclass, field
from urllib.parse import urlparse
@ -99,6 +108,38 @@ _OBLAST_AVITO_URL_PATTERNS = tuple(
)
# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться
# в ОДНУ строку, иначе перепись бесполезна.
_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+")
def _failure_signature(exc: BaseException) -> str:
"""Подпись причины отказа: тип исключения + текст без URL.
Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили
`attempted=1576, failed=1576, blocked=0` и ничего больше. Кто отказал,
площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот
пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают
события уровня ERROR, а поштучные отказы WARNING. Разница между этими
двумя диагнозами разные владельцы задачи (#2686, #2698), поэтому она
обязана переживать перезапуск контейнера, то есть лежать в самом прогоне.
Тип исключения первый разряд диагноза (AvitoBlockedError = площадка
показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт;
ValueError = ответ пришёл, но не разобран), текст второй.
"""
message = _URL_IN_MESSAGE_RE.sub("<url>", str(exc)).strip()
return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__
def _top_failure(census: Counter[str]) -> str | None:
"""Самая частая причина отказа с её долей; None — отказов не было."""
if not census:
return None
reason, hits = census.most_common(1)[0]
return f"{reason} ({hits} из {sum(census.values())})"
@dataclass
class AvitoDetailBackfillResult:
"""Counters for one backfill run."""
@ -139,6 +180,8 @@ async def run_avito_detail_backfill(
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5.
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
@ -149,6 +192,13 @@ async def run_avito_detail_backfill(
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
# Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы —
# нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток,
# получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600
# запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка
# мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый
# прогон, а 25 отказов подряд без единого успеха — уже не невезение.
max_consecutive_failures = int(params.get("max_consecutive_failures", 25))
warm_batch = int(params.get("warm_batch", 500))
research_every = int(params.get("research_every", 50))
block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0))
@ -308,9 +358,13 @@ async def run_avito_detail_backfill(
)
consecutive_blocks = 0
consecutive_failures = 0
aborted_by_blocks = False
do_sleep = False
items_since_warm = 0
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
for idx, row in enumerate(snapshot):
# Budget guard
@ -429,8 +483,9 @@ async def run_avito_detail_backfill(
if use_curl:
items_since_warm += 1
consecutive_blocks = 0
consecutive_failures = 0
except AvitoListingGoneError:
except AvitoListingGoneError as gone_exc:
# #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed.
# Координатные дыры в lat-null очереди в основном dead-листинги;
# browser-mode рендерит их «Ошибка 404» без item-view → раньше это
@ -440,6 +495,10 @@ async def run_avito_detail_backfill(
# и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope
# (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь.
counters.gone += 1
# 404 — честный ответ площадки, значит тракт цел: серия отказов
# прерывается (блочный брейкер 404 не трогает — см. #2034).
consecutive_failures = 0
failure_census[_failure_signature(gone_exc)] += 1
try:
with db.begin_nested():
db.execute(
@ -481,6 +540,7 @@ async def run_avito_detail_backfill(
except (AvitoBlockedError, AvitoRateLimitedError) as e:
consecutive_blocks += 1
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
@ -538,12 +598,14 @@ async def run_avito_detail_backfill(
exc_info=True,
)
except TimeoutError:
except TimeoutError as e:
# asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias).
# Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший
# fetch отменён → листинг failed, переходим к следующему (loop не зависает,
# run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем.
counters.failed += 1
consecutive_failures += 1
failure_census[_failure_signature(e)] += 1
logger.warning(
"avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip",
run_id,
@ -557,6 +619,8 @@ async def run_avito_detail_backfill(
except Exception as e:
counters.failed += 1
consecutive_failures += 1
failure_census[_failure_signature(e)] += 1
logger.warning(
"avito_detail_backfill: run_id=%d listing %s failed: %s",
run_id,
@ -568,6 +632,18 @@ async def run_avito_detail_backfill(
except Exception:
pass
if consecutive_failures >= max_consecutive_failures:
logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без "
"единого успеха, частая причина: %s. enriched=%d attempted=%d",
run_id,
consecutive_failures,
_top_failure(failure_census) or "неизвестна",
counters.enriched,
counters.attempted,
)
break
if counters.attempted % 25 == 0:
current_counters = counters.to_dict()
runs_mod.update_heartbeat(db, run_id, current_counters)
@ -580,6 +656,7 @@ async def run_avito_detail_backfill(
current_counters,
source="avito_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
fail_hint=_top_failure(failure_census),
)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "

View file

@ -17,6 +17,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
снапшота уедет в следующую ночь через NULL detail_enriched_at.
Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии
у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML
@ -43,10 +51,27 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = [
"OFFER_URL_PATTERN",
"YandexDetailBackfillResult",
"run_yandex_detail_backfill",
]
# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс).
# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него
# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть
# отказ предрешён регуляркой, а не капчей.
#
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды.
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
@dataclass
class YandexDetailBackfillResult:
@ -55,6 +80,7 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int]:
@ -62,6 +88,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@ -105,6 +132,9 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
# (см. комментарий у константы): в очередь не берём то, что заведомо
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
snapshot = (
db.execute(
text(
@ -114,23 +144,53 @@ async def run_yandex_detail_backfill(
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
),
{"batch_size": batch_size},
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
counters.unenrichable_pending = int(
db.execute(
text(
"""
SELECT count(*)
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url !~ CAST(:offer_url_pattern AS text)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN},
).scalar_one()
)
if counters.unenrichable_pending:
logger.info(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: "
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
"до сети",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,
)
if not snapshot:
logger.info(
"yandex_detail_backfill: run_id=%d -- no pending listings "
"(detail_enriched_at IS NULL = 0), done",
run_id,
)
runs_mod.mark_done(db, run_id, current_counters)
# to_dict(), а не current_counters: пустая очередь при непустом
# unenrichable_pending — самый важный случай этого счётчика.
runs_mod.mark_done(db, run_id, counters.to_dict())
return counters
logger.info(

View file

@ -0,0 +1,66 @@
-- 228_scrape_proxies_browser_health.sql
-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723).
--
-- WHY:
-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор
-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ
-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла:
-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable
-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие).
--
-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит
-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная
-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА
-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в
-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в
-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) —
-- поле РЯДОМ, а не новое значение существующего флага.
--
-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails):
-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что
-- атрибутируются узлу (сайдкар лежит / страница пустая —
-- не считаются, см. proxy_pool._classify_browser_probe).
-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл.
-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для
-- HTTP, непригоден для браузера». acquire() такой узел НЕ
-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а
-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только
-- если свободных пригодных нет.
-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё
-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска
-- camoufox (~8с замерено на проде), ipify — миллисекунды.
--
-- IDEMPOTENCY / SAFETY:
-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную
-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна
-- существующая выборка не меняет результат (все три колонки новые). Повторный
-- прогон — no-op (auto-apply strict на деплое это требует).
--
-- Dependencies: 157_scrape_proxies.sql
BEGIN;
ALTER TABLE scrape_proxies
ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz,
ADD COLUMN IF NOT EXISTS browser_check_at timestamptz;
COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS
'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), '
'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая '
'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной '
'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.';
COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS
'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не '
'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает '
'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему '
'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.';
COMMENT ON COLUMN scrape_proxies.browser_check_at IS
'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): '
'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. '
'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл '
'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.';
COMMIT;

View file

@ -0,0 +1,262 @@
-- 230_house_merge_log.sql
-- Журнал слияний домов + обратная операция (#2690).
--
-- WHY:
-- `house_dedup_merge` — НЕ спящая идея, а живой деструктивный проход: расписание
-- `house_dedup_merge` на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов
-- с 2026-06-27 уже удалили 119 строк `houses` (счётчики losers_deleted в scrape_runs:
-- 2/39/31/9/6/32). Единственным следом «кто в кого» была строка `logger.info` в контейнере,
-- а логи ротируются быстрее суток. То есть **уже сегодня** нельзя назвать, какой дом в какой
-- свернули 1 августа, — не говоря о том, чтобы вернуть.
--
-- Пока этого журнала нет, любой разговор о расширении ключа схлопывания (#2690, #1772)
-- ведётся без права на ошибку: единственный откат — restore всей БД на момент до прогона,
-- т.е. выброс недели сбора. Журнал снимает это условие: слияние становится обратимым,
-- и вопрос о ключе можно пересматривать, а не решать «навсегда».
--
-- Правку НЕ следует читать как одобрение текущего ключа/победителя/гео-стража. Она к ним
-- НЕЙТРАЛЬНА: ни ключ, ни правило выбора победителя, ни гео-страж здесь не меняются.
-- Меняется только одно — теперь есть что откатить.
--
-- WHAT (одна строка = один проигравший дом):
-- merge_pass / cluster_key / geo_guard / distance_m — ОСНОВАНИЕ слияния. Это не косметика:
-- ровно этих полей не хватило в #2690, чтобы ответить на вопрос «сколько слияний прошло
-- на расстояниях, которые гео-страж заблокировал бы» по ДАННЫМ, а не по ревью. distance_m
-- пишется всегда, даже когда страж для прохода выключен (fias-проход) — тогда он и есть
-- единственная запись о том, насколько далеко разъехались объединённые дома.
-- loser_row — ПОЛНЫЙ jsonb-снимок удаляемой строки (`to_jsonb(h.*)`, все 86 колонок).
-- Ссылка на удалённую строку бесполезна, поэтому хранится содержимое. Снимок целиком,
-- а не список полей: проверено, что `jsonb_populate_record(NULL::houses, loser_row)`
-- восстанавливает строку побайтово, включая PostGIS-geom (to_jsonb отдаёт её GeoJSON'ом,
-- populate_record разбирает обратно входной функцией типа). Побочная выгода: новая
-- колонка в `houses` попадает в снимок и в откат САМА, без правки этой миграции.
-- keeper_before — снимок ПОБЕДИТЕЛЯ до переноса метаданных. Нужен, потому что слияние не
-- только удаляет проигравшего: `_CARRY_OVER_IDENTITY_SQL` дозаполняет победителю NULL-поля
-- идентичности (fias/кадастр/ГАР/DaData) значениями проигравшего. Без этого снимка откат
-- вернул бы дом, но оставил бы его ФИАС на победителе — и следующий же fias-проход слил
-- бы их обратно.
-- children_repointed — {"таблица.колонка": [id, ...]}. Дочерние строки ПЕРЕЖИЛИ слияние,
-- у них сменилась только ссылка, поэтому хранятся id, а не содержимое (иначе одни
-- listings с их raw-payload'ом дали бы ~7 КБ на строку вместо ~8 байт на id).
-- children_deleted — {"таблица": [{строка целиком}, ...]}. Дочерние строки, которые проход
-- УДАЛИЛ из-за коллизии по UNIQUE. Их содержимое уничтожено, id недостаточно — только
-- полный снимок. Таких таблиц шесть (см. _STEPS), строки мелкие.
-- batch_id — один вызов merge_duplicate_houses() (оба прохода). Единица отката.
-- run_id / initiator — кто инициировал: scrape_runs.id для расписания, NULL для ручного.
--
-- НАМЕРЕННО БЕЗ ВНЕШНИХ КЛЮЧЕЙ на houses(id) и scrape_runs(id):
-- журнал обязан ПЕРЕЖИВАТЬ строки, которые описывает. loser_id указывает на заведомо
-- удалённый дом. keeper_id — на дом, который сам может быть слит следующим прогоном; FK
-- с CASCADE стёр бы историю ровно тогда, когда она нужнее всего, а FK без CASCADE
-- заблокировал бы слияние. То же с run_id: чистка scrape_runs не должна трогать журнал.
--
-- ОБЪЁМ (замерено на проде 2026-08-06):
-- 9 571 дом, средняя строка houses в jsonb 2 581 Б. Строка журнала ≈ loser_row 2.5 КБ +
-- keeper_before 2.5 КБ + списки id (в среднем 27.9 дочерних строк на дом × ~8 Б) ≈ 5.3 КБ.
-- Наблюдаемый темп — 20 слияний в неделю (119 за 6 прогонов) ≈ 106 КБ/нед ≈ 5.5 МБ/год.
-- Ближайший прогон (замер тем же выражением, что и код): 93 проигравших ≈ 0.5 МБ.
-- Абсолютный потолок, если схлопнуть вообще все дома: 9 571 × 5.3 КБ ≈ 50 МБ против 23 МБ
-- самой таблицы houses.
--
-- RETENTION: НЕ НУЖЕН, сознательно. Потолок роста — двузначные мегабайты, то есть дешевле
-- любой процедуры чистки; а журнал слияний — это ровно то, что удалять не хочется: его
-- ценность в том, что он отвечает на вопрос «что было год назад», когда логов давно нет.
-- Если объём когда-нибудь станет проблемой, удалять надо не строки, а тяжёлые снимки
-- (loser_row/keeper_before → NULL) у записей старше N лет, сохранив соответствие
-- loser→keeper: оно весит байты и именно оно нужно дольше всего.
--
-- Dependencies: 002_core_tables.sql (houses), 135_scrape_schedules_seed_house_dedup_merge.sql
-- Пишется в ТОЙ ЖЕ транзакции, что и слияние (см. house_dedup_merge._run_merge_pass) —
-- разрыв «слияние прошло, запись не легла» невозможен по построению; dry_run откатывает и то,
-- и другое вместе.
BEGIN;
CREATE TABLE IF NOT EXISTS house_merge_log (
id bigserial PRIMARY KEY,
merged_at timestamptz NOT NULL DEFAULT now(),
batch_id uuid NOT NULL,
run_id bigint,
initiator text NOT NULL,
merge_pass text NOT NULL,
cluster_key text NOT NULL,
geo_guard boolean NOT NULL,
distance_m double precision,
norm_address text,
loser_id bigint NOT NULL,
keeper_id bigint NOT NULL,
loser_row jsonb NOT NULL,
keeper_before jsonb NOT NULL,
children_repointed jsonb NOT NULL DEFAULT '{}'::jsonb,
children_deleted jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_loser ON house_merge_log (loser_id);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_keeper ON house_merge_log (keeper_id);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_batch ON house_merge_log (batch_id);
COMMENT ON TABLE house_merge_log IS
'Журнал слияний домов (#2690): одна строка = один проигравший дом, удалённый проходом '
'house_dedup_merge. Пишется в ТОЙ ЖЕ транзакции, что и слияние. Содержит полный снимок '
'удалённой строки и перечень перенесённых/удалённых дочерних строк — достаточно, чтобы '
'назвать поимённо, что во что свернули, и вернуть обратно (house_merge_undo). Намеренно '
'БЕЗ FK на houses/scrape_runs: журнал переживает строки, которые описывает. Retention нет.';
COMMENT ON COLUMN house_merge_log.cluster_key IS
'ЗНАЧЕНИЕ ключа, по которому дома попали в один кластер («addr:вайнера66» / «fias:<uuid>»), '
'а не имя ключа — по нему видно, какое именно совпадение сработало.';
COMMENT ON COLUMN house_merge_log.geo_guard IS
'Был ли для этого прохода включён гео-страж 250 м. false = слияние разрешено БЕЗ проверки '
'близости; вместе с distance_m это и есть аудит основания (#2690).';
COMMENT ON COLUMN house_merge_log.distance_m IS
'ST_DistanceSphere между победителем и проигравшим на момент слияния; NULL = у одной из '
'сторон не было geom. Пишется ВСЕГДА, в том числе когда гео-страж выключен.';
COMMENT ON COLUMN house_merge_log.loser_row IS
'to_jsonb() удалённой строки houses целиком. Восстановление: '
'INSERT INTO houses SELECT r.* FROM jsonb_populate_record(NULL::houses, loser_row) r.';
COMMENT ON COLUMN house_merge_log.keeper_before IS
'Снимок победителя ДО переноса метаданных с проигравшего (COALESCE-дозаполнение полей '
'идентичности). Без него откат вернул бы дом, но оставил его ФИАС/кадастр на победителе.';
COMMENT ON COLUMN house_merge_log.children_repointed IS
'{"таблица.колонка": [id, ...]} — дочерние строки, у которых слияние сменило ссылку '
'loser→keeper. Строки целы, поэтому хранятся id: откат возвращает ссылку обратно.';
COMMENT ON COLUMN house_merge_log.children_deleted IS
'{"таблица": [{строка целиком}, ...]} — дочерние строки, УДАЛЁННЫЕ проходом из-за коллизии '
'по UNIQUE с победителем. Содержимое уничтожено, поэтому хранится снимок, а не id.';
-- ── Обратная операция ────────────────────────────────────────────────────────
--
-- Откат одного батча (или его части) по журналу. Транзакционен: вызывающий сам решает
-- COMMIT/ROLLBACK, увидев отчёт. Возвращает СТРОКУ НА КАЖДУЮ запись журнала со статусом —
-- в том числе «не смог», потому что молчаливо-успешный откат хуже отсутствующего.
--
-- Порядок внутри одной записи важен: сначала воскресить дом (на него ссылаются дети), потом
-- вернуть ссылки детей, потом вернуть удалённых детей, потом снять перенос метаданных с
-- победителя. Записи батча обходятся в обратном порядке (id DESC) — если дом A слили в B,
-- а B потом в C, разматывать надо с конца.
--
-- ИЗВЕСТНЫЕ ГРАНИЦЫ (сознательные, отражены в статусе):
-- * дочерняя строка, удалённая по коллизии, может не вернуться: место в UNIQUE-ключе занято
-- строкой победителя. ON CONFLICT DO NOTHING + счётчик в статусе, а не тихая потеря;
-- * backfill-строки house_sources/house_address_aliases, которые проход дописал победителю,
-- НЕ удаляются: они собраны из собственных полей победителя и остались бы верны и без
-- слияния;
-- * если id проигравшего уже занят — запись пропускается со статусом, откат не гадает.
CREATE OR REPLACE FUNCTION house_merge_undo(
p_batch uuid,
p_only_losers bigint[] DEFAULT NULL
)
RETURNS TABLE (
out_log_id bigint,
out_loser_id bigint,
out_keeper_id bigint,
out_status text
)
LANGUAGE plpgsql
AS $$
DECLARE
rec record;
v_table text;
v_column text;
v_ids bigint[];
v_rows jsonb;
v_field text;
v_repointed int;
v_restored int;
v_lost int;
v_n int;
-- Список полей ДОЛЖЕН совпадать с SET в house_dedup_merge._CARRY_OVER_IDENTITY_SQL;
-- за расхождением следит тест test_undo_carryover_fields_match_merge_carryover.
c_carry_fields constant text[] := ARRAY[
'house_fias_id', 'cadastral_number', 'gar_house_guid', 'gar_flat_count',
'gar_matched_at', 'gar_match_method', 'dadata_qc_geo', 'dadata_qc_house',
'dadata_enriched_at'
];
BEGIN
FOR rec IN
SELECT *
FROM house_merge_log l
WHERE l.batch_id = p_batch
AND (p_only_losers IS NULL OR l.loser_id = ANY (p_only_losers))
ORDER BY l.id DESC
LOOP
out_log_id := rec.id;
out_loser_id := rec.loser_id;
out_keeper_id := rec.keeper_id;
IF EXISTS (SELECT 1 FROM houses h WHERE h.id = rec.loser_id) THEN
out_status := 'skipped: houses.id ' || rec.loser_id || ' занят — уже откачено?';
RETURN NEXT;
CONTINUE;
END IF;
-- 1. Воскресить проигравшего целиком из снимка (все колонки, включая geom).
INSERT INTO houses
SELECT r.* FROM jsonb_populate_record(NULL::houses, rec.loser_row) r;
-- 2. Вернуть ссылки уцелевших детей. Условие «сейчас указывает на победителя»
-- защищает от затирания строк, которые после слияния перепривязали чем-то ещё.
v_repointed := 0;
FOR v_table, v_column, v_ids IN
SELECT split_part(e.key, '.', 1),
split_part(e.key, '.', 2),
ARRAY(SELECT jsonb_array_elements_text(e.value)::bigint)
FROM jsonb_each(rec.children_repointed) AS e
LOOP
EXECUTE format(
'UPDATE %I SET %I = $1 WHERE id = ANY ($2) AND %I = $3',
v_table, v_column, v_column
) USING rec.loser_id, v_ids, rec.keeper_id;
GET DIAGNOSTICS v_n = ROW_COUNT;
v_repointed := v_repointed + v_n;
END LOOP;
-- 3. Вернуть детей, удалённых по коллизии UNIQUE. Место могло остаться занятым
-- строкой победителя — тогда DO NOTHING, и это попадёт в отчёт как «не вернулось».
v_restored := 0;
v_lost := 0;
FOR v_table, v_rows IN
SELECT e.key, e.value FROM jsonb_each(rec.children_deleted) AS e
LOOP
EXECUTE format(
'INSERT INTO %I SELECT r.* FROM jsonb_array_elements($1) AS el, '
'LATERAL jsonb_populate_record(NULL::%I, el) r ON CONFLICT DO NOTHING',
v_table, v_table
) USING v_rows;
GET DIAGNOSTICS v_n = ROW_COUNT;
v_restored := v_restored + v_n;
v_lost := v_lost + (jsonb_array_length(v_rows) - v_n);
END LOOP;
-- 4. Снять перенос метаданных с победителя. Только там, где до слияния было NULL И
-- текущее значение всё ещё РОВНО то, что принёс этот проигравший: если поле успел
-- заполнить загрузчик (или донором был другой проигравший кластера) — не трогаем.
-- Сравнение в jsonb-пространстве, чтобы один цикл покрыл text/int/timestamptz.
FOREACH v_field IN ARRAY c_carry_fields LOOP
IF rec.keeper_before ->> v_field IS NULL THEN
EXECUTE format(
'UPDATE houses SET %I = NULL WHERE id = $1 AND to_jsonb(%I) = $2',
v_field, v_field
) USING rec.keeper_id, rec.loser_row -> v_field;
END IF;
END LOOP;
out_status := format(
'restored: дом %s вернулся, ссылок возвращено %s, дочерних строк восстановлено %s'
|| CASE WHEN v_lost > 0 THEN ', НЕ ВЕРНУЛОСЬ ' || v_lost || ' (место занято)'
ELSE '' END,
rec.loser_id, v_repointed, v_restored
);
RETURN NEXT;
END LOOP;
END;
$$;
COMMENT ON FUNCTION house_merge_undo(uuid, bigint[]) IS
'Откат слияния домов по журналу house_merge_log (#2690). Аргументы: batch_id (единица '
'отката = один вызов merge_duplicate_houses) и опциональный список loser_id для частичного '
'отката. Возвращает строку-статус на КАЖДУЮ запись журнала, включая неудачные. '
'Транзакции не открывает и не закрывает — вызывающий смотрит отчёт и решает COMMIT/ROLLBACK: '
' BEGIN; SELECT * FROM house_merge_undo(''<batch_id>''); -- прочитать статусы -- COMMIT;';
COMMIT;

View file

@ -0,0 +1,293 @@
"""Unit-тесты `app.services.payments.tbank_client.TBankClient`.
NEVER calls real T-Bank API только `httpx.MockTransport` (тот же паттерн,
что `tests/services/tgbot/test_client.py` и `tests/services/test_dadata.py`).
`asyncio.sleep` патчится no-op'ом, чтобы retry-тесты шли мгновенно
независимо от реального backoff.
"""
from __future__ import annotations
from collections.abc import Callable
from unittest import mock
import httpx
import pytest
from app.services.payments.tbank_client import TBankApiError, TBankClient
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _install_transport(handler: Callable[[httpx.Request], httpx.Response]) -> None:
transport = httpx.MockTransport(handler)
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
mock.patch("app.services.payments.tbank_client.httpx.AsyncClient", factory).start()
@pytest.fixture(autouse=True)
def _stop_patches_and_noop_sleep():
sleep_patcher = mock.patch(
"app.services.payments.tbank_client.asyncio.sleep", return_value=None
)
sleep_patcher.start()
yield
mock.patch.stopall()
def _client(**kwargs: object) -> TBankClient:
defaults: dict[str, object] = {
"terminal_key": "MerchantTerminalKey",
"password": "test-password",
"base_url": "https://rest-api-test.tinkoff.ru",
}
defaults.update(kwargs)
return TBankClient(**defaults) # type: ignore[arg-type]
# ── happy path ────────────────────────────────────────────────────────────────
async def test_init_payment_happy_path_returns_payment_url() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/v2/Init")
return httpx.Response(
200,
json={
"Success": True,
"TerminalKey": "MerchantTerminalKey",
"Status": "NEW",
"PaymentId": "12345",
"OrderId": "order-1",
"Amount": 10000,
"PaymentURL": "https://securepay.tinkoff.ru/abc",
},
)
_install_transport(handler)
client = _client()
result = await client.init_payment(order_id="order-1", amount_kopecks=10000)
assert result["PaymentId"] == "12345"
assert result["PaymentURL"] == "https://securepay.tinkoff.ru/abc"
async def test_init_payment_signs_request_with_token() -> None:
"""Запрос обязан содержать TerminalKey + Token в теле."""
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
_install_transport(handler)
client = _client()
await client.init_payment(order_id="00000", amount_kopecks=19200)
body = captured["body"]
assert isinstance(body, dict)
assert body["TerminalKey"] == "MerchantTerminalKey"
assert isinstance(body.get("Token"), str) and len(body["Token"]) == 64
async def test_init_payment_receipt_excluded_from_signed_token_but_present_in_body() -> None:
"""`Receipt` уходит в тело запроса, но не участвует в Token (см. token.py)."""
import json
from app.services.payments.token import sign
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
_install_transport(handler)
client = _client()
receipt = {"Email": "a@test.ru", "Taxation": "osn", "Items": []}
await client.init_payment(order_id="00000", amount_kopecks=19200, receipt=receipt)
body = captured["body"]
assert isinstance(body, dict)
assert body["Receipt"] == receipt
# Token, реально ушедший в теле, обязан совпадать с sign() тела БЕЗ Receipt
# (Receipt — dict, sign() сам его игнорирует) — пересчитаем и сверим.
without_token = {k: v for k, v in body.items() if k != "Token"}
assert body["Token"] == sign(without_token, "test-password")
async def test_get_state_posts_to_correct_path() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/v2/GetState")
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
_install_transport(handler)
client = _client()
result = await client.get_state(payment_id="12345")
assert result["Status"] == "CONFIRMED"
async def test_check_order_posts_to_correct_path() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/v2/CheckOrder")
return httpx.Response(200, json={"Success": True, "Payments": []})
_install_transport(handler)
client = _client()
result = await client.check_order(order_id="order-1")
assert result["Payments"] == []
async def test_confirm_posts_to_correct_path() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/v2/Confirm")
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
_install_transport(handler)
client = _client()
result = await client.confirm(payment_id="12345")
assert result["Status"] == "CONFIRMED"
async def test_cancel_posts_to_correct_path() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/v2/Cancel")
return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"})
_install_transport(handler)
client = _client()
result = await client.cancel(payment_id="12345", amount_kopecks=5000)
assert result["Status"] == "REFUNDED"
# ── retry policy ─────────────────────────────────────────────────────────────
async def test_retries_on_5xx_then_succeeds() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"})
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
_install_transport(handler)
client = _client()
result = await client.init_payment(order_id="1", amount_kopecks=100)
assert result["PaymentId"] == "1"
assert calls["n"] == 3
async def test_retries_on_network_error_then_succeeds() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] < 2:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
_install_transport(handler)
client = _client()
result = await client.init_payment(order_id="1", amount_kopecks=100)
assert result["PaymentId"] == "1"
assert calls["n"] == 2
async def test_gives_up_after_max_retries_on_persistent_5xx() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"})
_install_transport(handler)
client = _client()
with pytest.raises(TBankApiError) as exc_info:
await client.get_state(payment_id="1")
assert exc_info.value.error_code == "500"
async def test_does_not_retry_on_4xx() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"})
_install_transport(handler)
client = _client()
with pytest.raises(TBankApiError) as exc_info:
await client.init_payment(order_id="1", amount_kopecks=100)
assert exc_info.value.error_code == "401"
assert calls["n"] == 1 # НЕ ретраится
async def test_business_failure_success_false_raises_without_retry() -> None:
"""HTTP 200, но `Success: false` — бизнес-отказ банка, не сбой транспорта."""
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(
200,
json={
"Success": False,
"ErrorCode": "9999",
"Message": "Неверные параметры запроса",
"Details": "Amount must be positive",
},
)
_install_transport(handler)
client = _client()
with pytest.raises(TBankApiError) as exc_info:
await client.init_payment(order_id="1", amount_kopecks=100)
assert exc_info.value.error_code == "9999"
assert exc_info.value.message == "Неверные параметры запроса"
assert calls["n"] == 1 # НЕ ретраится
async def test_malformed_json_response_raises_tbank_api_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"not json at all")
_install_transport(handler)
client = _client()
with pytest.raises(TBankApiError):
await client.get_state(payment_id="1")
# ── безопасность: пароль не попадает в тело запроса ─────────────────────────
async def test_password_never_sent_in_request_body() -> None:
import json
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
_install_transport(handler)
client = _client(password="super-secret-password")
await client.init_payment(order_id="1", amount_kopecks=100)
body = captured["body"]
assert isinstance(body, dict)
assert "Password" not in body
assert "super-secret-password" not in json.dumps(body)

View file

@ -188,9 +188,14 @@ class FakeSession:
and _not_banned(r)
and (not protects_last_node or _has_backup(r))
]
# ORDER BY last_ok_at NULLS LAST, id
# ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id.
# Первый ключ гейтим по подстроке самого SQL (как ban-фильтры выше): иначе
# мок сортировал бы «правильно» независимо от боевого запроса и не отличил
# бы код до #2723 от кода после.
deprioritises_unfit = "browser_unfit_since IS NOT NULL" in sql
cands.sort(
key=lambda r: (
bool(deprioritises_unfit and r.get("browser_unfit_since") is not None),
r["last_ok_at"] is None,
r["last_ok_at"] or datetime.min.replace(tzinfo=UTC),
r["id"],
@ -250,6 +255,14 @@ class FakeSession:
row["enabled"] = True
elif "enabled" in sql:
row["enabled"] = True
# #2723: если боевой mark_health когда-нибудь снова начнёт обнулять
# ещё и браузерный вердикт (как делал до фикса — тот жил в общем
# consecutive_fails), мок обязан это воспроизвести, иначе
# test_ipify_success_does_not_erase_browser_verdict останется зелёным
# на сломанном коде.
if "browser_fail_streak = 0" in sql:
row["browser_fail_streak"] = 0
row["browser_unfit_since"] = None
if "RETURNING disabled_reason" in sql:
return _FakeResult([{"disabled_reason": row.get("disabled_reason")}])
return _FakeResult([])
@ -272,8 +285,54 @@ class FakeSession:
if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff
]
rows = sorted(cands, key=lambda r: r["id"])
# #2723: браузерная проба со своим тактом. Признак считаем, только если
# боевой SQL его реально запрашивает (см. гейты по подстрокам выше).
if "browser_probe_due" in sql:
b_cutoff = datetime.now(UTC) - timedelta(minutes=p["browser_probe_minutes"])
return _FakeResult(
[
dict(
r,
browser_probe_due=(
r.get("browser_check_at") is None
or r["browser_check_at"] < b_cutoff
),
)
for r in rows
]
)
return _FakeResult([dict(r) for r in rows])
if "SET browser_fail_streak = 0" in sql: # mark_browser_health ok (#2723)
row = self._by_id(p["id"])
if row is None:
return _FakeResult([])
was_unfit = row.get("browser_unfit_since") is not None
row["browser_fail_streak"] = 0
row["browser_unfit_since"] = None
row["browser_check_at"] = datetime.now(UTC)
return _FakeResult([{"was_unfit": was_unfit}])
if "browser_fail_streak = browser_fail_streak + 1" in sql: # mark_browser_health fail
row = self._by_id(p["id"])
if row is None:
return _FakeResult([])
row["browser_fail_streak"] = row.get("browser_fail_streak", 0) + 1
if row["browser_fail_streak"] >= p["threshold"]:
if row.get("browser_unfit_since") is None:
row["browser_unfit_since"] = datetime.now(UTC)
# такт двигаем ТОЛЬКО на подтверждённом провале — иначе неподтверждённое
# подозрение ждало бы полный BROWSER_PROBE_MINUTES (#2723)
row["browser_check_at"] = datetime.now(UTC)
return _FakeResult(
[
{
"browser_fail_streak": row["browser_fail_streak"],
"browser_unfit_since": row.get("browser_unfit_since"),
}
]
)
if "pg_advisory_xact_lock" in sql: # deep-review fix 2 (#2600) — mark_banned serialize
self.advisory_lock_calls.append(p["key"])
return _FakeResult([])
@ -384,6 +443,9 @@ def _proxy(
kind: str = "http",
rotate_url: str | None = None,
disabled_reason: str | None = None,
browser_unfit_since: datetime | None = None,
browser_fail_streak: int = 0,
browser_check_at: datetime | None = None,
) -> dict[str, Any]:
return {
"id": pid,
@ -400,6 +462,10 @@ def _proxy(
"last_check_at": last_check_at,
"exit_ip": None,
"latency_ms": None,
# #2723: здоровье браузерного тракта — отдельные поля, миграция 228.
"browser_unfit_since": browser_unfit_since,
"browser_fail_streak": browser_fail_streak,
"browser_check_at": browser_check_at,
}

View file

@ -790,3 +790,88 @@ async def test_backfill_use_curl_block_cooldown_research_no_rebuild() -> None:
mock_scraper.return_value._rotate_ip.assert_not_called()
runs.mark_backfill_finished.assert_called_once()
runs.mark_failed.assert_not_called()
# ---------------------------------------------------------------------------
# Отказы-не-блоки: брейкер + перепись причин (2026-08-06)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_aborts_on_consecutive_failures_and_names_the_reason() -> None:
"""Серия отказов без единого успеха обрывается, а причина попадает в статус прогона.
Прод 3-5 августа: attempted1600, failed1600, blocked=0, enriched=0, весь
бюджет 9000 с и 1600 запросов через единственный прокси и ни слова о том,
ЧТО именно отказало (поштучные отказы логируются WARNING, а логи контейнера
пропадают на первом деплое). Брейкера на отказы-не-блоки не было вовсе.
"""
snapshot = _make_snapshot(200)
db = _mock_db(snapshot)
runs = MagicMock()
# Тот же класс отказа, что видели у соседнего свипа в тот же день.
mock_fetch = AsyncMock(
side_effect=OSError(
"Failed to perform, curl: (56) CONNECT tunnel failed, response 502. "
"See https://curl.se/libcurl/c/libcurl-errors.html"
)
)
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=True)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db,
run_id=77,
params={
"batch_size": 200,
"budget_sec": 3600,
"max_consecutive_failures": 25,
},
)
assert result.attempted == 25, "серия отказов обязана обрываться, а не выедать бюджет"
assert result.failed == 25
assert result.blocked == 0
hint = runs.mark_backfill_finished.call_args.kwargs["fail_hint"]
assert hint is not None
assert "OSError" in hint # тип исключения = кому принадлежит отказ
assert "CONNECT tunnel failed" in hint
assert "25 из 25" in hint # доля, а не единичный пример
assert "https://curl.se" not in hint # URL вырезан, иначе 1600 «разных» причин
@pytest.mark.asyncio
async def test_backfill_success_resets_failure_streak() -> None:
"""Успех между отказами обнуляет серию — здоровый прогон брейкер не трогает."""
snapshot = _make_snapshot(5)
db = _mock_db(snapshot)
runs = MagicMock()
boom = ValueError("avito detail HTTP 500 for https://www.avito.ru/x")
# 2 отказа, успех, 2 отказа — при пороге 3 ни одна серия его не достигает.
mock_fetch = AsyncMock(side_effect=[boom, boom, MagicMock(), boom, boom])
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=True)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db,
run_id=78,
params={"batch_size": 5, "budget_sec": 3600, "max_consecutive_failures": 3},
)
assert result.attempted == 5
assert result.enriched == 1
assert result.failed == 4

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import re
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -50,11 +51,13 @@ def _make_snapshot(n: int) -> list[dict]:
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
"""Fake Session: first execute() returns snapshot via .mappings().all()."""
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
.scalar_one() размер отброшенной (непарсимой) части очереди."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.scalar_one.return_value = unenrichable
db.execute.return_value = sel
return db
@ -448,3 +451,59 @@ def test_save_detail_enrichment_rowcount_zero_returns_false() -> None:
saved = save_detail_enrichment(db, listing_id=404, e=enrichment)
assert saved is False
# ---------------------------------------------------------------------------
# Очередь не должна содержать того, что парсер отвергает до сети (2026-08-06)
# ---------------------------------------------------------------------------
# Реальные source_url с прода (2026-08-06). Верх очереди на момент прогона 3300
# состоял ровно из таких строк: 5 попыток, 5 parse-None, abort за 23 секунды.
_PROD_QUEUE_HEAD = [
("https://macroserver.ru/id/224566/", False),
("https://prospect-federation.ru/flat/192", False),
("https://macroserver.ru/id/7223953/", False),
("https://strana.com/ekaterinburg/flat/1234", False),
("https://realty.yandex.ru/offer/7416316701146842927/", True),
("https://realty.yandex.ru/offer/7298311881327827251/", True),
]
@pytest.mark.asyncio
async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
"""Снапшот-SELECT судит по тому же признаку, что и парсер, — сторожем, а не на слово.
`YandexDetailScraper.parse` возвращает None по регулярке в URL, ещё не
заглянув в HTML. Строки шире этого условия гарантированно дают parse-None и
пачкой выбивают брейкер «5 подряд», обрывая ВЕСЬ прогон (32 прогона из 53 на
проде). Проверяем на одних и тех же прод-URL обе стороны + что отброшенное
посчитано, а не молча исчезло.
"""
from scraper_kit.providers.yandex.detail import YandexDetailScraper
db = _mock_db([], unenrichable=3535)
runs = MagicMock()
session_cls, _session = _make_session_ctx([])
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
patch(_SETTINGS, _mock_settings()),
):
result = await run_yandex_detail_backfill(
db, run_id=42, params={"batch_size": 10, "budget_sec": 60}
)
snapshot_call = db.execute.call_args_list[0]
assert "source_url ~ CAST(:offer_url_pattern AS text)" in str(snapshot_call.args[0])
pattern = snapshot_call.args[1]["offer_url_pattern"]
scraper = YandexDetailScraper()
for url, enrichable in _PROD_QUEUE_HEAD:
assert (re.search(pattern, url) is not None) is enrichable, url
if not enrichable:
# HTML тут любой: отказ предрешён до его разбора.
assert scraper.parse("<html><body>сайт застройщика</body></html>", url) is None, url
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535

View file

@ -0,0 +1,329 @@
"""#2723 — проба здоровья прокси ходит тем же трактом, что и работа.
Что сторожится (каждый тест падает на коде origin/main):
1. Классификация отказа браузерной пробы: узлу принадлежит ТОЛЬКО отказ прокси
(503 «browser unavailable», 500 NS_ERROR_PROXY_*). Лежащий сайдкар и пустая
страница не его вина. Без этого одна упавшая общая зависимость пометила бы
непригодными ВСЕ узлы разом #2686 в третий раз.
2. Тракт пробы: POST /fetch (одна навигация) на robots.txt, с прокси узла в теле.
Не /fetch-json (тот сначала грузит ГЛАВНУЮ площадки) и не выдача.
3. Главное: успешная ipify-проба НЕ стирает вердикт браузерного тракта. На коде до
фикса узел, мёртвый для браузера, выходил из карантина каждые 30 минут
(mark_health(ok=True) consecutive_fails=0 + enabled=true) и снова забирал прогон.
4. Два диагноза разведены в обе стороны: провал браузерной пробы НЕ выключает узел
и НЕ трогает consecutive_fails; провал ipify не пишет ничего в browser-поля.
5. Пометка непригодности НЕ выводит узел из пула: acquire() отдаёт его последним,
но при отсутствии пригодных всё равно выдаёт (голодание хуже) пул из 4 узлов.
6. Реанимация: успешная браузерная проба снимает пометку (browser_refit).
7. Такт: браузерная проба идёт реже ipify (BROWSER_PROBE_MINUTES) и только по узлам,
прошедшим ipify иначе на каждый прогон приходился бы запуск camoufox на узел.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from scraper_kit.browser_fetcher import classify_browser_probe
from app.services import proxy_pool
from app.services.proxy_pool import BROWSER_PROBE_MINUTES, BROWSER_UNFIT_THRESHOLD, acquire
from tests.services.test_proxy_pool import FakeSession, _proxy
# ── 1. классификация отказа ──────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "detail", "expected"),
[
# Ровно тот текст, которым сайдкар отвечал на все 90 записанных обрывов сбора.
(503, '{"error": "browser unavailable (proxy may be down)"}', "proxy"),
(500, '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}', "proxy"),
(500, '{"error": "Error: Page.goto: NS_ERROR_UNKNOWN_PROXY_HOST"}', "proxy"),
# Сайдкар не сконфигурирован / лежит / отвечает чем-то ещё — узел ни при чём.
(503, '{"error": "no proxy configured — refusing direct connection (prod)"}', "sidecar"),
(502, "bad gateway", "sidecar"),
(None, "ConnectError: [Errno 111] Connection refused", "sidecar"),
# Тракт сработал, но ответ не похож на страницу — вопрос к площадке, не к пулу.
(200, "", "page"),
],
)
def test_classify_browser_probe(status: int | None, detail: str, expected: str) -> None:
assert classify_browser_probe(status, detail) == expected
def test_sidecar_error_literals_still_exist() -> None:
"""Тripwire: классификация опирается на текст отказа сайдкара — сторожим его.
Если browser/server.py переименует сообщение, «proxy» перестанет распознаваться и
непригодный узел молча останется первосортным. Тест падает СРАЗУ, а не через месяц
зелёных проб (ровно тот сценарий, из-за которого задача и появилась).
"""
server_py = Path(__file__).resolve().parents[2] / "browser" / "server.py"
src = server_py.read_text(encoding="utf-8")
assert "browser unavailable (proxy may be down)" in src
# ── 2. тракт пробы ───────────────────────────────────────────────────────────
async def test_probe_goes_through_sidecar_with_node_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
"""Проба = POST /fetch на robots.txt с прокси УЗЛА в теле, а не httpx-GET мимо всех."""
seen: dict[str, Any] = {}
class _Resp:
status_code = 200
text = '{"html": "<html>User-agent: *</html>"}'
@staticmethod
def json() -> dict[str, str]:
return {"html": "<html>User-agent: *</html>"}
class _Client:
def __init__(self, **kw: Any) -> None:
seen["timeout"] = kw.get("timeout")
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
seen["url"] = url
seen["payload"] = json
return _Resp()
import scraper_kit.browser_fetcher as bf
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
"http://tradein-browser:3000", "http://u:p@node:8080", proxy_kind="http"
)
assert ok is True
assert fail_kind is None
# тот же сайдкар и тот же эндпоинт, что у боевого сбора
assert seen["url"] == "http://tradein-browser:3000/fetch"
# НЕ /fetch-json: он делает goto на главную площадки — это уже нагрузка на неё
assert not seen["url"].endswith("/fetch-json")
# прокси проверяемого узла уезжает в тело — иначе camoufox пойдёт через env-прокси
# и проба снова будет измерять не тот узел
assert seen["payload"]["proxy"] == "http://u:p@node:8080"
# адрес — robots.txt площадки, не выдача и не карточка
assert seen["payload"]["url"].endswith("/robots.txt")
assert "avito.ru" in seen["payload"]["url"]
# ── 3-4. два диагноза разведены ──────────────────────────────────────────────
def test_ipify_success_does_not_erase_browser_verdict() -> None:
"""ГЛАВНОЕ: успешная ipify-проба не воскрешает узел, мёртвый для браузера.
До #2723 браузерный вердикт жил в consecutive_fails, и mark_health(ok=True)
обнулял его каждые 30 минут вместе с enabled=true.
"""
db = FakeSession([_proxy(1)])
for _ in range(BROWSER_UNFIT_THRESHOLD):
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
row = db._by_id(1)
assert row["browser_unfit_since"] is not None
proxy_pool.mark_health(db, 1, True, exit_ip="1.2.3.4", latency_ms=100)
row = db._by_id(1)
assert row["consecutive_fails"] == 0 # HTTP-диагноз сброшен, как и раньше
assert row["browser_unfit_since"] is not None # а браузерный — НЕТ
assert row["browser_fail_streak"] >= BROWSER_UNFIT_THRESHOLD
def test_browser_failure_does_not_disable_node() -> None:
"""Обратная сторона: провал браузерного тракта не выключает живой узел."""
db = FakeSession([_proxy(1)])
for _ in range(BROWSER_UNFIT_THRESHOLD + 3):
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
row = db._by_id(1)
assert row["enabled"] is True # узел жив для HTTP — из пула не выводим
assert row["consecutive_fails"] == 0 # и транспортный счётчик не трогаем
assert row["browser_unfit_since"] is not None
def test_sidecar_outage_blames_nobody() -> None:
"""Лежащий сайдкар не должен пометить непригодными все узлы разом (#2686-класс)."""
db = FakeSession([_proxy(1), _proxy(2)])
for pid in (1, 2):
for _ in range(BROWSER_UNFIT_THRESHOLD + 1):
outcome = proxy_pool.mark_browser_health(
db, pid, False, fail_kind="sidecar", detail="ConnectError"
)
assert outcome == "ignored"
for pid in (1, 2):
assert db._by_id(pid)["browser_unfit_since"] is None
assert db._by_id(pid)["browser_fail_streak"] == 0
def test_unconfirmed_failure_keeps_check_at_stale() -> None:
"""Первый (неподтверждённый) провал не двигает такт — перепроверка на след. прогоне."""
db = FakeSession([_proxy(1, browser_check_at=None)])
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
assert db._by_id(1)["browser_fail_streak"] == 1
assert db._by_id(1)["browser_check_at"] is None # такт не сдвинут
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
assert db._by_id(1)["browser_unfit_since"] is not None
assert db._by_id(1)["browser_check_at"] is not None # подтверждён → ждём полный такт
# ── 5. пометка не выводит узел из пула ───────────────────────────────────────
def test_unfit_node_is_last_in_queue_but_still_reachable() -> None:
old = datetime.now(UTC) - timedelta(hours=5)
db = FakeSession(
[
# непригодный, но «давно не использованный» → до #2723 выдавался ПЕРВЫМ
_proxy(1, last_ok_at=old, browser_unfit_since=datetime.now(UTC)),
_proxy(2, last_ok_at=datetime.now(UTC)),
]
)
lease = acquire(db, "avito") # type: ignore[arg-type]
assert lease is not None
assert lease.id == 2 # пригодный вперёд, несмотря на ORDER BY last_ok_at
def test_all_unfit_still_yields_a_proxy() -> None:
"""Все узлы непригодны — система НЕ остаётся без прокси (голодание хуже)."""
db = FakeSession(
[
_proxy(1, browser_unfit_since=datetime.now(UTC)),
_proxy(2, browser_unfit_since=datetime.now(UTC)),
]
)
lease = acquire(db, "avito") # type: ignore[arg-type]
assert lease is not None
# ── 6-7. healthcheck: такт, гейт, реанимация ─────────────────────────────────
def _patch_probes(
monkeypatch: pytest.MonkeyPatch,
*,
http_ok: bool = True,
browser: tuple[bool, str | None, str] = (True, None, "html_len=100"),
calls: list[str] | None = None,
) -> None:
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
return (True, "1.2.3.4", 10, None) if http_ok else (False, None, None, "timeout")
async def _fake_browser(
endpoint: str, proxy_url: str, **_kw: Any
) -> tuple[bool, str | None, str]:
if calls is not None:
calls.append(proxy_url)
return browser
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
import scraper_kit.browser_fetcher as bf
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
async def test_healthcheck_marks_unfit_when_http_green_browser_red(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Исторический случай целиком: ipify зелёная, браузер красный → диагноз ставится."""
calls: list[str] = []
_patch_probes(
monkeypatch,
http_ok=True,
browser=(False, "proxy", "503 browser unavailable (proxy may be down)"),
calls=calls,
)
db = FakeSession([_proxy(1)])
first = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert first["ok"] == 1 and first["failed"] == 0 # HTTP-проба по-прежнему зелёная
assert first["browser_checked"] == 1
assert db._by_id(1)["browser_unfit_since"] is None # один провал ещё не приговор
second = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert second["browser_unfit"] == 1
row = db._by_id(1)
assert row["browser_unfit_since"] is not None
assert row["enabled"] is True and row["consecutive_fails"] == 0
assert len(calls) == 2
async def test_healthcheck_browser_probe_respects_slow_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
calls: list[str] = []
_patch_probes(monkeypatch, calls=calls)
db = FakeSession([_proxy(1)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 1
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
minutes=BROWSER_PROBE_MINUTES + 1
)
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 2
async def test_healthcheck_skips_browser_probe_when_http_dead(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Узел, не прошедший ipify, мёртв целиком — жечь на него запуск camoufox незачем."""
calls: list[str] = []
_patch_probes(monkeypatch, http_ok=False, calls=calls)
db = FakeSession([_proxy(1)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["failed"] == 1
assert counters["browser_checked"] == 0
assert calls == []
async def test_healthcheck_skips_browser_probe_when_pool_not_wired(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Флаг выключен → браузер ходит мимо пула, вердикт об узлах пула бессмыслен."""
calls: list[str] = []
_patch_probes(monkeypatch, calls=calls)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", False)
db = FakeSession([_proxy(1)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["browser_checked"] == 0
assert calls == []
async def test_healthcheck_revives_unfit_node(monkeypatch: pytest.MonkeyPatch) -> None:
"""Путь обратно: успешная браузерная проба снимает пометку непригодности."""
_patch_probes(monkeypatch)
db = FakeSession(
[
_proxy(
1,
browser_unfit_since=datetime.now(UTC) - timedelta(days=1),
browser_fail_streak=4,
browser_check_at=datetime.now(UTC) - timedelta(minutes=BROWSER_PROBE_MINUTES + 1),
)
]
)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["browser_refit"] == 1
row = db._by_id(1)
assert row["browser_unfit_since"] is None
assert row["browser_fail_streak"] == 0

View file

@ -53,6 +53,39 @@ def test_days_param_uses_cast_as_int() -> None:
assert "CAST(:days AS int)" in _AUDIT_SRC
def test_every_group_by_username_filters_out_the_nameless() -> None:
"""Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715).
Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО
того, как мы посмотрели на присланное имя, и записать его нельзя иначе
атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом
такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке
(её `last_seen_at` момент атаки), даёт фантома в `count(DISTINCT
username)`, а раскрытие уходит в `/audit/accounts/{username}` с
`min_length=1` то есть в ошибку.
То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы
безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу
пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её
в NULL, который `count(DISTINCT)` не считает. Сами события при этом из
`total_events` не исчезают они события, просто не люди.
Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку,
добавленную без фильтра, а не только сегодняшние. На проде пустых имён
сейчас 0 из 3365 строк то есть это ново.
"""
grouped = _AUDIT_SRC.count("GROUP BY username")
filtered = _AUDIT_SRC.count("WHERE username <> ''")
assert grouped == filtered, (
f"{grouped} выборок GROUP BY username, из них с фильтром {filtered}"
"безымянная строка попадёт в список аккаунтов"
)
assert "count(DISTINCT username)" not in _AUDIT_SRC, (
"count(DISTINCT username) считает безымянные события за людей — "
"нужен count(DISTINCT NULLIF(username, ''))"
)
# ---------------------------------------------------------------------------
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
# ---------------------------------------------------------------------------

View file

@ -32,6 +32,7 @@ in-memory fake DB standing in for the identity registry:
from __future__ import annotations
import asyncio
import logging
import os
import re
import time
@ -259,6 +260,10 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
auth_router._USERNAME_FAIL_LIMITER._hits.clear()
# Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса
# недосчитанные отказы одного теста всплывают в записи другого.
monkeypatch.setattr(auth_router, "_saturation_rejected", 0)
monkeypatch.setattr(auth_router, "_saturation_reported_at", None)
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`.
@ -958,6 +963,183 @@ async def test_flood_from_one_ip_leaves_login_open_for_another_ip(
)
# ---------------------------------------------------------------------------
# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог
# ---------------------------------------------------------------------------
def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None:
"""Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок.
Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий
предикат отказа (`verify_slots_saturated` читает этот же глобал), а не
собственную заглушку.
"""
monkeypatch.setattr(password_mod, "_verify_inflight", 999)
def test_saturated_login_answers_before_touching_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Под насыщением отказ приходит ДО выборки пользователя (#2715).
Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно
брал соединение из пула тогда, когда система уже перегружена. Хуже того,
эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий
время ответа для существующего и несуществующего имени, до отказанного
запроса не доходит вовсе, так что разницу маскировать было нечем.
Мерим не тайминг (в CI флейкует), а сам факт похода в реестр и заодно
побайтовую одинаковость ответа для живого и выдуманного имени.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
_saturate_verify_slots(monkeypatch)
bodies = []
for name in ("alice", "ghost"):
resp = client.post(
"/api/v1/auth/login",
json={"username": name, "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
assert resp.headers["Retry-After"] == "1"
bodies.append(resp.text)
assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}"
# Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже).
assert bodies[0] == bodies[1]
def test_key_share_alone_also_answers_before_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной.
Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated`
коротит на первой половине: выброси вторую и тот тест останется зелёным.
Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается
вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все
отклонённые запросы.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
# Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно
# его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают.
monkeypatch.setattr(password_mod, "_verify_inflight", 2)
monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2})
assert password_mod._per_key_slot_cap() == 2 # исходные условия теста
flooder = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert flooder.status_code == 429, flooder.text
assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}"
# И тут же — доказательство, что предчек не отказывает всем подряд: с
# ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр.
other = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "wrong"},
headers={"x-forwarded-for": "198.51.100.10"},
)
assert other.status_code == 401, other.text
assert lookups == ["alice"]
def test_saturation_is_reported_once_per_window_and_lands_in_audit(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any
) -> None:
"""Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком.
Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m ×
max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за
минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно.
А событие в `user_events` потому что до #2715 инцидент не оставлял в
аудите ни строчки: событие неудачного входа тут не пишется намеренно
(пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой
учётки насыщением) значит нужен отдельный тип события, и он обязан
появляться независимо от того, ротировался лог или нет.
"""
# ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя»
# против «не выселяет лог», и подъём его до минут прячет атаку целиком.
assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0
events = _capture_events(monkeypatch)
_saturate_verify_slots(monkeypatch)
# Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна
# и число записей стало бы функцией скорости раннера.
monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0)
caplog.set_level(logging.WARNING, logger="app.api.v1.auth")
for i in range(20):
resp = client.post(
"/api/v1/auth/login",
json={"username": f"ghost{i}", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
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
# Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна),
# поэтому в первой записи он один — накопленное придёт следующей.
assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None}
assert saturated[0]["ip"] == "203.0.113.5"
# Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись
# присланного дала бы атакующему аудит-строки с любым именем на выбор.
assert saturated[0]["username"] == ""
# Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход.
assert [e for e in events if e["event_type"] == "login_failed"] == []
assert not auth_router._USERNAME_FAIL_LIMITER._hits
# Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу.
monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0)
resp = client.post(
"/api/v1/auth/login",
json={"username": "ghost-last", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 2
assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян"
# Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились
# они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО
# отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT.
assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0)
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------

View file

@ -284,9 +284,18 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None:
assert "keeper_geom IS NOT NULL" in canon
assert "loser_geom IS NOT NULL" in canon
# fias pass drops the distance guard AND the NULL-geom exclusions entirely.
assert "ST_DistanceSphere" not in fias
assert "loser_geom IS NOT NULL" not in fias
assert "keeper_geom IS NOT NULL" not in fias
#
# Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also
# MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes.
# Measuring is the opposite of guarding — the fias pass is precisely where nothing else records
# how far apart the merged rows were — so the name alone can no longer stand in for the guard.
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
guard = (
"AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL "
"AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
)
assert guard in canon
assert guard not in fias
# the cross-fias anti-over-merge guard is untouched in the canon pass.
assert "lower(loser_fias) <> lower(keeper_fias)" in canon
@ -294,12 +303,15 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None:
def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None:
"""_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias
guard is emitted regardless, and the default is True (canon-safe)."""
guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True))
without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False))
assert "ST_DistanceSphere" in with_guard
assert "ST_DistanceSphere" not in without_guard
assert guard in with_guard
assert guard not in without_guard
# default = True (the canon pass must never lose its guard by omission).
assert "ST_DistanceSphere" in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
# ...while the journal's distance MEASUREMENT is emitted either way (#2690).
assert "AS distance_m" in with_guard and "AS distance_m" in without_guard
# cross-fias guard present in BOTH renderings (independent of the geo guard).
assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard
assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard
@ -327,7 +339,9 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None:
assert token in canon and token in fias
# the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity.
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon
assert "ST_DistanceSphere" not in fias
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
# ...but the journal's distance MEASUREMENT is on both — measuring is not guarding.
assert "AS distance_m" in canon and "AS distance_m" in fias
def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None:
@ -433,28 +447,62 @@ class _FakeResult:
class _Row:
def __init__(self, loser_id: int, keeper_id: int, norm_address: str):
def __init__(
self,
loser_id: int,
keeper_id: int,
norm_address: str,
cluster_key: str = "addr:тест",
distance_m: float | None = 12.0,
):
self.loser_id = loser_id
self.keeper_id = keeper_id
self.norm_address = norm_address
# journal grounds (#2690): which key value fired, and how far apart the rows were.
self.cluster_key = cluster_key
self.distance_m = distance_m
class _ChildRow:
"""What a step's RETURNING yields: an id for a survivor, a snapshot for a destroyed row."""
def __init__(self, loser_id: int, child_id: int = 1):
self.loser_id = loser_id
self.child_id = child_id
self.row_snapshot = {"id": child_id, "house_id": loser_id}
class _FKChild:
def __init__(self, child_table: str, fk_column: str):
self.child_table = child_table
self.fk_column = fk_column
class _FakeDB:
"""Session stand-in: build-mapping + a scripted SELECT result, then per-step rowcounts."""
def __init__(self, mapping_rows: list[_Row], step_rowcount: int = 1):
def __init__(
self,
mapping_rows: list[_Row],
step_rowcount: int = 1,
fk_children: dict[str, str] | None = None,
):
self._mapping_rows = mapping_rows
self._step_rowcount = step_rowcount
self._mapping_served = False
# The catalog the FK-child guard reads; defaults to the real live set.
self._fk_children = _FK_CHILDREN if fk_children is None else fk_children
self.commits = 0
self.rollbacks = 0
self.executed: list[str] = []
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
def execute(self, clause: Any, params: Any = None) -> _FakeResult:
sql = str(getattr(clause, "text", clause))
self.executed.append(sql)
if "CREATE TEMP TABLE" in sql:
return _FakeResult()
if "FROM pg_constraint" in sql:
return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()])
if "SELECT loser_id, keeper_id, norm_address" in sql:
# The service now runs TWO passes (fias, then canon). Model «fias pass found the
# duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards.
@ -462,7 +510,13 @@ class _FakeDB:
return _FakeResult(rows=[])
self._mapping_served = True
return _FakeResult(rows=list(self._mapping_rows))
# any UPDATE/DELETE/INSERT step (incl. DROP TABLE, carry-over, delete, backfill)
# Steps now RETURN the rows they touched (journal, #2690) — one per scripted rowcount,
# attributed to the first loser so the per-loser bookkeeping has something to bucket.
if "RETURNING" in sql:
loser = self._mapping_rows[0].loser_id if self._mapping_rows else 0
rows = [_ChildRow(loser, child_id=i + 1) for i in range(self._step_rowcount)]
return _FakeResult(rowcount=self._step_rowcount, rows=rows)
# any other UPDATE/DELETE/INSERT (DROP TABLE, journal, carry-over, delete, backfill)
return _FakeResult(rowcount=self._step_rowcount)
def commit(self) -> None:
@ -524,7 +578,11 @@ def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr(
hdm,
"merge_duplicate_houses",
lambda _db, dry_run=False: {"clusters_merged": 3, "losers_deleted": 5, "dry_run": 0},
lambda _db, dry_run=False, run_id=None, initiator="manual": {
"clusters_merged": 3,
"losers_deleted": 5,
"dry_run": 0,
},
)
out = hdm.run_house_dedup_merge(object(), run_id=42, params={"dry_run": False}) # type: ignore[arg-type]
@ -541,13 +599,20 @@ def test_run_wrapper_passes_dry_run_param(monkeypatch: pytest.MonkeyPatch) -> No
monkeypatch.setattr(runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(runs_mod, "mark_failed", lambda *a, **k: None)
def _fake_merge(_db: Any, dry_run: bool = False) -> dict[str, int]:
def _fake_merge(
_db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual"
) -> dict[str, int]:
captured["dry_run"] = dry_run
captured["run_id"] = run_id
captured["initiator"] = initiator
return {"dry_run": int(dry_run)}
monkeypatch.setattr(hdm, "merge_duplicate_houses", _fake_merge)
hdm.run_house_dedup_merge(object(), run_id=1, params={"dry_run": True}) # type: ignore[arg-type]
assert captured["dry_run"] is True
# the journal must be able to say WHICH run did it, and that it was not a human (#2690)
assert captured["run_id"] == 1
assert captured["initiator"] == "schedule"
def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
@ -562,7 +627,9 @@ def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> N
lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
)
def _boom(_db: Any, dry_run: bool = False) -> dict[str, int]:
def _boom(
_db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual"
) -> dict[str, int]:
raise RuntimeError("merge exploded")
monkeypatch.setattr(hdm, "merge_duplicate_houses", _boom)
@ -642,12 +709,16 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None:
db = _live_session()
assert db is not None
try:
# Two houses at the SAME address. Keeper (geom present) should win.
# Two houses at the SAME address, ~10 m apart (the #2187 canon geo guard needs geom
# on BOTH sides). Keeper = min(id) once geom and listing counts tie.
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES "
"(900001, 'avito', 'EXT-KEEP', 'тестдом 1772, 1', 56.84, 60.60),"
"(900002, 'cian', 'EXT-LOSE', 'тестдом 1772, 1', NULL, NULL)"
# url is NOT NULL in houses (002_core_tables); nothing here asserts on it,
# so 'u' is a placeholder. These live-DB fixtures self-skip in CI, which is
# how they silently drifted out of sync with the schema in the first place.
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
"(900001, 'avito', 'EXT-KEEP','u', 'тестдом 1772, 1', 56.84, 60.60),"
"(900002, 'cian', 'EXT-LOSE','u', 'тестдом 1772, 1', 56.84009, 60.60)"
)
)
# listings pointing at BOTH (the loser's must be re-pointed). source_url, dedup_hash,
@ -755,6 +826,9 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None:
db.execute(
_t("DELETE FROM house_address_aliases WHERE normalized_address = 'тестдом 1772, 1'")
)
# journal rows have no FK and are never cascaded away — sweep them explicitly,
# or a re-run accumulates them (all live fixtures live in the 9000xx id range).
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id IN (900001,900002)"))
db.commit()
db.close()
@ -781,16 +855,16 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
try:
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES "
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
# A — ул/улица spelling variants of the SAME building, ~10 m apart → MERGE
"(900010, 'avito', 'EXT-T-VK', 'улица Тестовая1772, 66', 56.84000, 60.60000),"
"(900011, 'cian', 'EXT-T-VL', 'ул. Тестовая1772, 66', 56.84009, 60.60000),"
"(900010, 'avito', 'EXT-T-VK','u', 'улица Тестовая1772, 66', 56.84000, 60.60000),"
"(900011, 'cian', 'EXT-T-VL','u', 'ул. Тестовая1772, 66', 56.84009, 60.60000),"
# B — same canon (ленина-like) but ~5 km apart → geo guard BLOCKS the merge
"(900012, 'avito', 'EXT-T-L1', 'улица Тестовая1772, 5', 56.84000, 60.60000),"
"(900013, 'cian', 'EXT-T-L2', 'улица Тестовая1772, 5', 56.88500, 60.60000),"
"(900012, 'avito', 'EXT-T-L1','u', 'улица Тестовая1772, 5', 56.84000, 60.60000),"
"(900013, 'cian', 'EXT-T-L2','u', 'улица Тестовая1772, 5', 56.88500, 60.60000),"
# C — different корпус → different canon, ~10 m apart → NOT merged
"(900014, 'avito', 'EXT-T-M2', 'Тестовая1772, 34к2', 56.84000, 60.60000),"
"(900015, 'cian', 'EXT-T-M4', 'Тестовая1772, 34к4', 56.84009, 60.60000)"
"(900014, 'avito', 'EXT-T-M2','u', 'Тестовая1772, 34к2', 56.84000, 60.60000),"
"(900015, 'cian', 'EXT-T-M4','u', 'Тестовая1772, 34к4', 56.84009, 60.60000)"
)
)
db.execute(
@ -840,7 +914,7 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
db.execute(
_t(
"DELETE FROM house_sources WHERE ext_id IN "
"('EXT-T-VK','EXT-T-VL','EXT-T-L1','EXT-T-L2','EXT-T-M2','EXT-T-M4')"
"('EXT-T-VK','u','EXT-T-VL','u','EXT-T-L1','u','EXT-T-L2','u','EXT-T-M2','u','EXT-T-M4')"
)
)
db.execute(
@ -850,6 +924,9 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
"'тестовая1772, 34к2','тестовая1772, 34к4')"
)
)
# journal rows have no FK and are never cascaded away — sweep them explicitly,
# or a re-run accumulates them (all live fixtures live in the 9000xx id range).
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900010 AND 900015"))
db.commit()
db.close()
@ -877,21 +954,21 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
db.execute(
_t(
"INSERT INTO houses "
"(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid, "
"(id, source, ext_house_id, url, address, lat, lon, house_fias_id, gar_house_guid, "
" dadata_enriched_at) VALUES "
# A — same fias, different canon (different streets), ~10 m apart → FIAS-pass merge
"(900020,'avito','EXT-F-K','ФиасОдин1772, 10', 56.84000,60.60000,"
"(900020,'avito','EXT-F-K','u','ФиасОдин1772, 10', 56.84000,60.60000,"
" 'F-SAME-1772',NULL,NULL),"
"(900021,'cian', 'EXT-F-L','СовсемДругая1772, 77',56.84009,60.60000,"
"(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000,"
" 'F-SAME-1772',NULL,NULL),"
# B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS
"(900022,'avito','EXT-B-1','Клара1772, 32',56.84000,60.60000,"
"(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000,"
" 'F-B1-1772',NULL,NULL),"
"(900023,'cian', 'EXT-B-2','Клара1772, 3/2',56.84009,60.60000,"
"(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000,"
" 'F-B2-1772',NULL,NULL),"
# C — same canon, fias only on the loser → canon-pass merge + carry-over
"(900024,'avito','EXT-C-K','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL),"
"(900025,'cian', 'EXT-C-L','Донбасс1772, 8',56.84009,60.60000,"
"(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL),"
"(900025,'cian', 'EXT-C-L','u','Донбасс1772, 8',56.84009,60.60000,"
" 'F-CARRY-1772','G-CARRY-1772',NOW())"
)
)
@ -944,7 +1021,7 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
db.execute(
_t(
"DELETE FROM house_sources WHERE ext_id IN "
"('EXT-F-K','EXT-F-L','EXT-B-1','EXT-B-2','EXT-C-K','EXT-C-L')"
"('EXT-F-K','u','EXT-F-L','u','EXT-B-1','u','EXT-B-2','u','EXT-C-K','u','EXT-C-L')"
)
)
db.execute(
@ -954,6 +1031,9 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
"'донбасс1772, 8')"
)
)
# journal rows have no FK and are never cascaded away — sweep them explicitly,
# or a re-run accumulates them (all live fixtures live in the 9000xx id range).
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900020 AND 900025"))
db.commit()
db.close()
@ -982,16 +1062,16 @@ def test_real_fias_pass_ignores_geo_guard() -> None:
db.execute(
_t(
"INSERT INTO houses "
"(id, source, ext_house_id, address, lat, lon, house_fias_id) VALUES "
"(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES "
# A — same fias, loser NULL geom → fias pass merges despite the missing coordinate
"(900030,'avito','EXT-2187-A-K','ФиасГеоA2187, 1', 56.84000,60.60000,'F-A-2187'),"
"(900031,'cian', 'EXT-2187-A-L','ФиасГеоAL2187, 2',NULL, NULL, 'F-A-2187'),"
"(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187'),"
"(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187'),"
# B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance
"(900032,'avito','EXT-2187-B-K','ФиасГеоB2187, 3', 56.84000,60.60000,'F-B-2187'),"
"(900033,'cian', 'EXT-2187-B-L','ФиасГеоBL2187, 4',56.88500,60.60000,'F-B-2187'),"
"(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187'),"
"(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187'),"
# C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged)
"(900034,'avito','EXT-2187-C-1','КанонГео2187, 5', 56.84000,60.60000,NULL),"
"(900035,'cian', 'EXT-2187-C-2','КанонГео2187, 5', 56.88500,60.60000,NULL)"
"(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL),"
"(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)"
)
)
# A loser gets a listing so we prove the re-point still fires with a NULL-geom loser.
@ -1028,6 +1108,199 @@ def test_real_fias_pass_ignores_geo_guard() -> None:
db.execute(_t("DELETE FROM listings WHERE id = 910031"))
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035"))
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035"))
# journal rows have no FK and are never cascaded away — sweep them explicitly,
# or a re-run accumulates them (all live fixtures live in the 9000xx id range).
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035"))
db.commit()
db.close()
# ── Merge journal: reversibility (#2690) ──────────────────────────────────────
def test_undo_carryover_fields_match_merge_carryover() -> None:
"""Static drift guard: migration 230's undo must un-set EXACTLY the fields the merge carries.
The undo NULLs the keeper's identity fields that the merge COALESCE-filled from a loser.
If _CARRY_OVER_IDENTITY_SQL ever gains a field and the migration's array does not, the undo
silently leaves that field on the keeper the restored loser and the keeper would then both
claim the same ФИАС, and the next fias pass would merge them straight back.
"""
migration = (_SQL_DIR / "230_house_merge_log.sql").read_text(encoding="utf-8")
carried = set(re.findall(r"^\s+(\w+)\s*=\s*COALESCE\(k\.", _CARRY_SQL, re.M))
# slice the ARRAY[...] literal itself — the declaration's own `text[]` also holds a «]»
block = migration[migration.index("c_carry_fields") :]
undone = set(re.findall(r"'(\w+)'", block[block.index("ARRAY[") : block.index("];")]))
assert carried, "could not parse carried fields out of _CARRY_OVER_IDENTITY_SQL"
assert carried == undone, f"carry-over/undo field drift: merge={carried} undo={undone}"
def test_journal_written_in_the_same_transaction_as_the_merge() -> None:
"""The journal INSERT must sit between mapping and delete, with no commit in between.
Requirement from #2690: a merge that commits without its journal row is exactly the failure
the journal exists to prevent, so the two must share one transaction.
"""
src = inspect.getsource(hdm._run_merge_pass)
assert "_JOURNAL_INSERT_SQL" in src
assert "db.commit()" not in src, "the pass must not commit — the caller owns the txn"
# phase 1 (snapshots) strictly before the steps mutate anything, delete strictly after.
assert src.index("_JOURNAL_INSERT_SQL") < src.index("for label, sql in _STEPS")
assert src.index("for label, sql in _STEPS") < src.index("_DELETE_LOSERS_SQL")
def test_every_step_returns_what_it_touched() -> None:
"""Each step must RETURN its rows: ids for survivors, full snapshots for destroyed rows."""
for label, sql in hdm._STEPS:
assert "RETURNING" in sql, f"{label}: no RETURNING — its rows would go unjournaled"
if label.endswith("(collision-delete)") or label.endswith("(dedup)"):
assert "to_jsonb(" in sql, f"{label}: destroys rows, must snapshot them, not ids"
else:
assert "AS child_id" in sql, f"{label}: re-points rows, must return their ids"
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_merge_is_reversible_via_journal() -> None:
"""End-to-end on a real DB: merge → journal is sufficient → undo restores the ORIGINAL state.
The comparison is over `to_jsonb(row.*)` for every row that existed before the merge all
columns, not a chosen pair for houses and for every FK child touched.
"""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
ids = "(900201, 900202)"
try:
# Keeper 900201 and loser 900202: same canon address, ~12 m apart (inside the 250 m
# guard), keeper has the listings so the keeper rule picks it.
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, geom, "
"year_built, house_fias_id, gar_flat_count, raw_payload) VALUES "
"(900201,'avito','K-2690','http://t/2690/k','улица Журнальная, 7', "
" 56.8400, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8400),4326), "
" 1979, NULL, NULL, '{\"k\":[1,2]}'), "
"(900202,'cian','L-2690','http://t/2690/l','ул. Журнальная,7', "
" 56.8401, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8401),4326), "
# NB: no «:word» inside the literal — SQLAlchemy text() would read it as a bind.
" NULL, 'fias-2690-uuid', 144, '{\"l\":{\"deep\":[3,4]}}')"
)
)
db.execute(
_t(
"INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, "
"house_id_fk) VALUES "
"(910201,'avito','http://t/2690/1','L1','dh-2690-1',5000000,900201),"
"(910202,'avito','http://t/2690/2','L2','dh-2690-2',5100000,900201),"
"(910203,'cian','http://t/2690/3','L3','dh-2690-3',6000000,900202)"
)
)
db.execute(
_t(
"INSERT INTO house_sources (house_id, ext_source, ext_id, confidence, "
"matched_method) VALUES (900201,'avito','S-2690-K',1.0,'t'),"
"(900202,'cian','S-2690-L',1.0,'t')"
)
)
# Colliding child: identical 6-col UNIQUE key on both → the loser's row is DESTROYED by
# the dedup step. Only a full snapshot can bring it back.
db.execute(
_t(
"INSERT INTO houses_price_dynamics (house_id, month_date, source, room_count, "
"prices_type, period, price_per_sqm) VALUES "
"(900201, DATE '2026-02-01','cian','all','priceSqm','allTime',100000),"
"(900202, DATE '2026-02-01','cian','all','priceSqm','allTime',999999)"
)
)
db.commit()
def snapshot() -> dict[tuple[str, int], Any]:
"""to_jsonb of every seeded row, keyed by (table, id) — the full-fidelity state."""
out: dict[tuple[str, int], Any] = {}
for tbl, col in (
("houses", "id"),
("listings", "house_id_fk"),
("house_sources", "house_id"),
("houses_price_dynamics", "house_id"),
):
where = f"id IN {ids}" if tbl == "houses" else f"{col} IN {ids}"
for r in db.execute(
_t(f"SELECT id, to_jsonb(t.*) AS j FROM {tbl} t WHERE {where}")
):
out[(tbl, r.id)] = r.j
return out
before = snapshot()
assert len(before) == 9, f"fixture should seed 9 rows, got {sorted(before)}"
# ── merge ──
out = hdm.merge_duplicate_houses(db, dry_run=False, initiator="test")
assert out["losers_deleted"] == 1
assert db.execute(_t(f"SELECT count(*) FROM houses WHERE id IN {ids}")).scalar() == 1
# ── the journal alone must be able to NAME what went into what ──
row = db.execute(
_t("SELECT * FROM house_merge_log WHERE loser_id = 900202 ORDER BY id DESC LIMIT 1")
).one()
assert (row.loser_id, row.keeper_id) == (900202, 900201)
assert row.merge_pass == "canon" and row.geo_guard is True
assert row.cluster_key.startswith("addr:")
assert 0 < row.distance_m < 250, "distance to the keeper must be recorded, in metres"
assert row.initiator == "test"
# full snapshot of the deleted row, not a reference to it
assert row.loser_row == before[("houses", 900202)]
# keeper as it stood BEFORE the identity carry-over (fias still empty there, filled now)
assert row.keeper_before["house_fias_id"] is None
assert (
db.execute(_t("SELECT house_fias_id FROM houses WHERE id = 900201")).scalar()
== "fias-2690-uuid"
), "carry-over should have moved the loser's fias up"
# children: the loser's listing moved by id, the destroyed price row by content
assert row.children_repointed["listings.house_id_fk"] == [910203]
assert [r["price_per_sqm"] for r in row.children_deleted["houses_price_dynamics"]] == [
999999
]
# ── undo ──
report = db.execute(
_t("SELECT * FROM house_merge_undo(CAST(:b AS uuid))"), {"b": str(row.batch_id)}
).all()
assert len(report) == 1 and report[0].out_status.startswith("restored:"), report
db.commit()
after = snapshot()
# every row that existed before is back, byte-identical, on every column
assert {k: v for k, v in after.items() if k in before} == before
# the ONLY residue is the house_sources row the merge backfilled for the keeper.
# migration 230 documents this: it is built from the keeper's OWN ext_house_id, so
# it would have been true without the merge too. Asserted, not assumed.
residue = [v for k, v in after.items() if k not in before]
assert all(v["matched_method"] == "backfill_dedup_merge" for v in residue), residue
finally:
db.rollback()
db.execute(_t(f"DELETE FROM listings WHERE house_id_fk IN {ids}"))
db.execute(_t("DELETE FROM listings WHERE id IN (910201,910202,910203)"))
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id = 900202"))
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id IN (900201,900202)"))
db.execute(_t(f"DELETE FROM houses WHERE id IN {ids}"))
db.commit()
db.close()
def test_merge_refuses_when_an_fk_child_is_unhandled() -> None:
"""A new FK child on houses(id) must ABORT the merge, not be CASCADE-deleted unjournaled.
9 of the 11 FKs are ON DELETE CASCADE. A child the _STEPS pipeline does not know about is
therefore destroyed by `DELETE FROM houses` no re-point step touches it, no RETURNING
records it, and the journal would claim a complete snapshot it does not have. Migration 133
already broke on prod over a missed child; there it failed loudly, here it would be silent.
"""
db = _FakeDB(
mapping_rows=[_Row(2, 1, "ул. ленина, 5")],
fk_children={**_FK_CHILDREN, "house_brand_new_child": "house_id"},
)
with pytest.raises(RuntimeError, match="house_brand_new_child"):
hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
assert db.commits == 0, "an unhandled child must abort before anything is committed"

View file

@ -0,0 +1,110 @@
"""Правило «из `async def` зови ТОЛЬКО ограниченную сверку» — проверяемое (#2715).
Правило живёт в docstring `app/core/password.py`: синхронный `verify_password`
блокирует поток на ~282 мс (bcrypt cost 12), поэтому из кода приложения его
зовёт РОВНО ОДНА функция `verify_password_bounded`, и она же единственная,
кто считает слоты (потолок темпа #2665 + доля на ключ #2714).
Комментарий это правило не удерживает. Синхронная функция остаётся публичной и
импортируемой, и достаточно одной строчки `asyncio.to_thread(verify_password,
)` в будущем коде, чтобы получить вынос в поток ВООБЩЕ БЕЗ учёта слотов:
внешне всё работает, вход отвечает быстро, а потолок перебора тихо исчезает.
Ревью такое ловит ровно до тех пор, пока помнит, что правило есть.
Прецедент такого сторожа в репозитории: backend/tests/sql/test_auth_sql_migrations.py.
ПОЧЕМУ AST, А НЕ GREP. `verify_password` упоминается в комментариях и docstring'ах
(app/api/v1/auth.py, app/core/config.py) текстовый поиск краснел бы на них, и
сторож пришлось бы ослаблять исключениями до бессмысленности. AST видит только
ССЫЛКИ НА СИМВОЛ и ловит форму без скобок (`to_thread(verify_password, )`),
которую `grep 'verify_password('` не поймал бы вовсе то есть ровно ту, ради
которой сторож и написан.
ЧЕГО СТОРОЖ НЕ ВИДИТ, и это записано тут, а не подразумевается: строкового
доступа (`getattr(mod, "verify_password")`) и обхода модуля целиком (прямой
`bcrypt.checkpw`). От НАМЕРЕННОГО обхода он не защищает и не может только от
нечаянного, а нечаянный и есть частый случай. Обе непойманные формы закреплены
исполняемо (`test_detector_blind_spots_are_known`), чтобы «не ловим» было
проверенным фактом, а не обещанием в тексте.
Без БД и без сети только чтение файлов.
"""
from __future__ import annotations
import ast
from pathlib import Path
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
_APP_DIR = _BACKEND_ROOT / "app"
# Единственное место, которому синхронная сверка разрешена: там она и определена,
# и оттуда её забирает пул внутри `verify_password_bounded`.
_OWNER = _APP_DIR / "core" / "password.py"
def _references_verify_password(source: str) -> bool:
"""Ссылается ли модуль на символ `verify_password` (в любой форме)."""
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Name) and node.id == "verify_password":
return True
if isinstance(node, ast.Attribute) and node.attr == "verify_password":
return True
if isinstance(node, ast.ImportFrom) and any(
alias.name == "verify_password" for alias in node.names
):
return True
return False
def test_detector_actually_detects() -> None:
"""Сторож обязан уметь краснеть — иначе он зелен вхолостую.
Проверка на самого себя: пустой детектор (`return False`) прошёл бы все
файлы приложения и выглядел бы работающим сторожем ровно до первого
настоящего нарушения.
"""
# Формы, которые обязан ловить.
assert _references_verify_password("from app.core.password import verify_password")
assert _references_verify_password("asyncio.to_thread(verify_password, plain, hashed)")
assert _references_verify_password("password.verify_password(plain, hashed)")
assert _references_verify_password("ok = verify_password(plain, hashed)")
# Формы, на которые краснеть НЕЛЬЗЯ (иначе сторож потребуют выключить).
assert not _references_verify_password("await verify_password_bounded(p, h, key=ip)")
assert not _references_verify_password('"""Зови verify_password только из пула."""')
assert not _references_verify_password("# verify_password тут только в комментарии")
def test_detector_blind_spots_are_known() -> None:
"""Слепые зоны — зафиксированы, а не забыты.
Обе формы обходят сторож НАМЕРЕННЫМ усилием: строковый доступ к атрибуту и
обход модуля целиком. Ловить их AST'ом можно было бы только ценой ложняков
(любой `getattr` с любой строкой, любой вызов bcrypt), а цена ложняка
требование выключить сторож. Тест держит это знание исполняемым: захочет
однажды детектор их ловить покраснеет здесь и заставит осознанно
переписать и этот тест, и текст модуля.
"""
assert not _references_verify_password('fn = getattr(password_mod, "verify_password")')
assert not _references_verify_password("bcrypt.checkpw(plain.encode(), hashed.encode())")
def test_sync_verify_password_is_called_from_one_place_only() -> None:
"""В `app/` синхронную сверку не поминает никто, кроме её собственного модуля."""
# Область сканирования жива. `rglob` по несуществующему каталогу не падает —
# отдаёт пусто, нарушителей ноль, сторож зелен НАВСЕГДА. Достаточно
# переложить этот файл в подкаталог tests/ (их уже восемь, и прецедент
# такого сторожа лежит именно в подкаталоге), чтобы `parents[1]` уехал.
assert _OWNER.exists(), f"область сканирования съехала: {_APP_DIR}"
offenders = [
str(path.relative_to(_BACKEND_ROOT))
for path in sorted(_APP_DIR.rglob("*.py"))
if path != _OWNER and _references_verify_password(path.read_text(encoding="utf-8"))
]
assert offenders == [], (
f"{offenders}: синхронный verify_password блокирует поток на ~282 мс и НЕ считает "
"слоты. Из кода приложения зови verify_password_bounded (app/core/password.py) — "
"она единственная точка выноса в пул и единственная точка учёта потолка"
)

View file

@ -0,0 +1,208 @@
"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга)."""
from __future__ import annotations
import pytest
from app.services.payments.receipt import (
ReceiptBuildError,
ReceiptItem,
build_receipt,
receipt_total_kopecks,
)
def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem:
return ReceiptItem(
name="Отчёт об оценке квартиры (электронный)",
price_kopecks=price_kopecks,
quantity=quantity,
tax=tax, # type: ignore[arg-type]
)
# ── happy path ────────────────────────────────────────────────────────────────
def test_build_receipt_happy_path_with_email() -> None:
receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com")
assert receipt["Taxation"] == "usn_income"
assert receipt["Email"] == "buyer@example.com"
assert "Phone" not in receipt
assert len(receipt["Items"]) == 1
item = receipt["Items"][0]
assert item["Name"] == "Отчёт об оценке квартиры (электронный)"
assert item["Price"] == 99000
assert item["Quantity"] == 1
assert item["Amount"] == 99000
assert item["Tax"] == "none"
assert item["PaymentMethod"] == "full_payment"
assert item["PaymentObject"] == "service"
def test_build_receipt_happy_path_with_phone_only() -> None:
receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000")
assert receipt["Phone"] == "+79990000000"
assert "Email" not in receipt
def test_build_receipt_accepts_both_email_and_phone() -> None:
receipt = build_receipt(
items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000"
)
assert receipt["Email"] == "buyer@example.com"
assert receipt["Phone"] == "+79990000000"
def test_amount_is_price_times_quantity() -> None:
item = _item(price_kopecks=10000, quantity=3)
assert item.amount_kopecks == 30000
payload = item.to_payload()
assert payload["Amount"] == 30000
# ── Email/Phone обязательность ──────────────────────────────────────────────
def test_build_receipt_requires_email_or_phone() -> None:
with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"):
build_receipt(items=[_item()], taxation="osn")
def test_build_receipt_rejects_blank_email_and_phone() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(items=[_item()], taxation="osn", email=" ", phone="")
# ── Items[].Name длина ───────────────────────────────────────────────────────
def test_item_name_exactly_128_chars_is_ok() -> None:
item = ReceiptItem(name="A" * 128, price_kopecks=1000)
payload = item.to_payload()
assert payload["Name"] == "A" * 128
def test_item_name_over_128_chars_rejected() -> None:
item = ReceiptItem(name="A" * 129, price_kopecks=1000)
with pytest.raises(ReceiptBuildError, match="128"):
item.to_payload()
def test_item_name_empty_rejected() -> None:
item = ReceiptItem(name="", price_kopecks=1000)
with pytest.raises(ReceiptBuildError):
item.to_payload()
# ── Price / Quantity валидация ──────────────────────────────────────────────
def test_item_zero_price_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=0)
with pytest.raises(ReceiptBuildError):
item.to_payload()
def test_item_negative_price_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=-100)
with pytest.raises(ReceiptBuildError):
item.to_payload()
def test_item_zero_quantity_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=100, quantity=0)
with pytest.raises(ReceiptBuildError):
item.to_payload()
# ── Taxation / Tax enum ──────────────────────────────────────────────────────
@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"])
def test_all_documented_taxation_values_accepted(taxation: str) -> None:
build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type]
def test_unknown_taxation_rejected() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(
items=[_item()],
taxation="usn",
email="a@test.ru", # type: ignore[arg-type]
)
@pytest.mark.parametrize(
"tax_rate",
["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"],
)
def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None:
"""Актуальный список 2026 года — все значения проходят валидацию."""
item = _item(tax=tax_rate)
payload = item.to_payload()
assert payload["Tax"] == tax_rate
@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"])
def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None:
"""vat20/vat120 сняты с актуального списка 2026 — не должны проходить."""
item = _item(tax=removed_rate)
with pytest.raises(ReceiptBuildError, match="vat20/vat120"):
item.to_payload()
# ── Items[] границы ──────────────────────────────────────────────────────────
def test_build_receipt_rejects_empty_items() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(items=[], taxation="osn", email="a@test.ru")
def test_build_receipt_rejects_more_than_100_items() -> None:
items = [_item() for _ in range(101)]
with pytest.raises(ReceiptBuildError, match="100"):
build_receipt(items=items, taxation="osn", email="a@test.ru")
def test_build_receipt_accepts_exactly_100_items() -> None:
items = [_item() for _ in range(100)]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
assert len(receipt["Items"]) == 100
# ── инвариант: сумма Items[].Amount == сумме заказа ─────────────────────────
def test_receipt_total_matches_order_amount_invariant() -> None:
"""Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа."""
order_amount_kopecks = 148500
items = [
ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"),
ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"),
]
receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru")
assert receipt_total_kopecks(receipt) == order_amount_kopecks
def test_receipt_total_multi_quantity_item() -> None:
items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
assert receipt_total_kopecks(receipt) == 20000
def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None:
assert receipt_total_kopecks({"Taxation": "osn"}) == 0
def test_receipt_total_mismatch_detected_by_caller() -> None:
"""Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount."""
items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
wrong_init_amount_kopecks = 99999
assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks

View file

@ -0,0 +1,198 @@
"""Тесты `app.services.payments.token` — подпись Token + проверка нотификаций.
Эталонные векторы (`test_sign_matches_official_init_vector`,
`test_sign_matches_official_notification_vector`) сняты ДОСЛОВНО живым
запросом (curl, 2026-08-06) с официального doc-портала Т-Банка:
- Init: https://developer.tbank.ru/eacq/intro/developer/token
(раздел «Сформировать токен»)
- Нотификация: https://developer.tbank.ru/eacq/intro/developer/notification
(раздел «Проверить токен уведомлений»)
Оба payload'а и оба итоговых hex-digest скопированы из HTML doc-портала
(не выдуманы) см. промежуточные шаги в комментариях у каждого теста.
Если хеш перестанет сходиться чинить `token.py`, НЕ тест.
"""
from __future__ import annotations
import hashlib
from app.services.payments.token import sign, verify_notification_token
# ── эталонный вектор №1: Init ────────────────────────────────────────────────
# Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания):
# 1) [{"TerminalKey": "MerchantTerminalKey"}, {"Amount": 19200},
# {"OrderId": "00000"}, {"Description": "Подарочная карта на 1000 рублей"}]
# 2) + {"Password": "11111111111111"}
# 3) отсортировано по ключу: Amount, Description, OrderId, Password, TerminalKey
# 4) конкатенация значений:
# "19200Подарочная карта на 1000 рублей0000011111111111111MerchantTerminalKey"
# 5) SHA-256 → "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2"
_INIT_VECTOR_PAYLOAD = {
"TerminalKey": "MerchantTerminalKey",
"Amount": 19200,
"OrderId": "00000",
"Description": "Подарочная карта на 1000 рублей",
}
_INIT_VECTOR_PASSWORD = "11111111111111"
_INIT_VECTOR_TOKEN = "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2"
def test_sign_matches_official_init_vector() -> None:
"""Официальный вектор Init из документации Т-Банка."""
assert sign(_INIT_VECTOR_PAYLOAD, _INIT_VECTOR_PASSWORD) == _INIT_VECTOR_TOKEN
# ── эталонный вектор №2: нотификация ─────────────────────────────────────────
# Doc-портал, шаг за шагом:
# 1) [{"TerminalKey": "1234567890DEMO"}, {"OrderId": "000000"},
# {"Success": true}, {"Status": "AUTHORIZED"}, {"PaymentId": "0000000"},
# {"ErrorCode": "0"}, {"Amount": 1111}, {"CardId": "000000"},
# {"Pan": "200000******0000"}, {"ExpDate": "1111"}, {"RebillId": "000000"}]
# 2) + {"Password": "11111111111"}
# 3) отсортировано: Amount, CardId, ErrorCode, ExpDate, OrderId, Pan,
# Password, PaymentId, RebillId, Status, Success, TerminalKey
# 4) конкатенация значений:
# "111100000001111000000200000******0000111111111110000000000000AUTHORIZEDtrue1234567890DEMO"
# 5) SHA-256 → "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8"
_NOTIFICATION_VECTOR_PAYLOAD = {
"TerminalKey": "1234567890DEMO",
"OrderId": "000000",
"Success": True,
"Status": "AUTHORIZED",
"PaymentId": "0000000",
"ErrorCode": "0",
"Amount": 1111,
"CardId": "000000",
"Pan": "200000******0000",
"ExpDate": "1111",
"RebillId": "000000",
}
_NOTIFICATION_VECTOR_PASSWORD = "11111111111"
_NOTIFICATION_VECTOR_TOKEN = "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8"
def test_sign_matches_official_notification_vector() -> None:
"""Официальный вектор нотификации (Success/AUTHORIZED) из документации Т-Банка."""
assert (
sign(_NOTIFICATION_VECTOR_PAYLOAD, _NOTIFICATION_VECTOR_PASSWORD)
== _NOTIFICATION_VECTOR_TOKEN
)
def test_verify_notification_token_accepts_valid_official_vector() -> None:
"""`verify_notification_token` — тот же вектор, но с полем Token внутри payload."""
payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN}
assert verify_notification_token(payload_with_token, _NOTIFICATION_VECTOR_PASSWORD) is True
def test_verify_notification_token_rejects_tampered_field() -> None:
"""Изменили Amount после подписи → Token больше не совпадает → False."""
tampered = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN, "Amount": 9999}
assert verify_notification_token(tampered, _NOTIFICATION_VECTOR_PASSWORD) is False
def test_verify_notification_token_rejects_wrong_password() -> None:
payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN}
assert verify_notification_token(payload_with_token, "wrong-password") is False
def test_verify_notification_token_rejects_missing_token() -> None:
assert verify_notification_token(dict(_NOTIFICATION_VECTOR_PAYLOAD), "any-password") is False
def test_verify_notification_token_rejects_empty_token() -> None:
payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": ""}
assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False
# ── unit-детали алгоритма ─────────────────────────────────────────────────────
def test_bool_true_becomes_lowercase_string() -> None:
"""`Success: True` (Python bool) → строка "true" в конкатенации."""
with_bool = sign({"A": True}, "pw")
with_string = sign({"A": "true"}, "pw")
assert with_bool == with_string
def test_bool_false_becomes_lowercase_string() -> None:
with_bool = sign({"A": False}, "pw")
with_string = sign({"A": "false"}, "pw")
assert with_bool == with_string
def test_int_amount_stringified_without_quotes_semantics() -> None:
"""`Amount: 1111` (int) даёт тот же результат, что и `Amount: "1111"` (str)."""
with_int = sign({"Amount": 1111}, "pw")
with_str = sign({"Amount": "1111"}, "pw")
assert with_int == with_str
def test_float_without_leading_zero_loss_and_no_exponent() -> None:
"""Дробное число сериализуется без экспоненты и без хвостовых нулей.
Ключи после добавления Password: "A" < "Password" (лексикографически),
поэтому конкатенация значение A, затем значение Password.
"""
raw = "1234.5" + "pw"
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert sign({"A": 1234.5}, "pw") == expected
def test_large_float_has_no_exponential_notation() -> None:
"""Очень большое число не сваливается в экспоненциальную запись (`1e+21`)."""
raw = "1000000000000000000000" + "pw"
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert sign({"A": 1e21}, "pw") == expected
def test_none_values_are_skipped() -> None:
"""`None`-поля не участвуют в конкатенации вообще (не как пустая строка)."""
with_none = sign({"A": "x", "B": None}, "pw")
without_key = sign({"A": "x"}, "pw")
assert with_none == without_key
def test_nested_receipt_dict_is_ignored() -> None:
"""Вложенный `Receipt` (dict) не участвует в подписи."""
without_receipt = sign({"A": "x"}, "pw")
with_receipt = sign({"A": "x", "Receipt": {"Email": "a@test.ru", "Items": []}}, "pw")
assert without_receipt == with_receipt
def test_nested_data_dict_is_ignored() -> None:
"""Вложенный `DATA`/`Data` (dict) не участвует в подписи — оба варианта регистра ключа."""
baseline = sign({"A": "x"}, "pw")
assert sign({"A": "x", "DATA": {"Phone": "+70000000000"}}, "pw") == baseline
assert sign({"A": "x", "Data": {"Phone": "+70000000000"}}, "pw") == baseline
def test_nested_list_items_is_ignored() -> None:
"""Вложенный список (`Items`/`Shops` как root-ключ) не участвует в подписи."""
baseline = sign({"A": "x"}, "pw")
assert sign({"A": "x", "Items": [{"Name": "тест"}]}, "pw") == baseline
assert sign({"A": "x", "Shops": [{"ShopCode": "1"}]}, "pw") == baseline
def test_existing_token_field_in_payload_is_excluded() -> None:
"""Если в payload уже есть `Token` (например, переподписываем нотификацию) — игнорируется."""
without_token = sign({"A": "x"}, "pw")
with_token = sign({"A": "x", "Token": "stale-value-from-previous-signing"}, "pw")
assert without_token == with_token
def test_sort_is_by_key_name_not_insertion_order() -> None:
"""Порядок вставки ключей в payload не влияет на результат — сортировка по ключу."""
forward = sign({"Zeta": "1", "Alpha": "2", "Mid": "3"}, "pw")
reversed_order = sign({"Mid": "3", "Alpha": "2", "Zeta": "1"}, "pw")
assert forward == reversed_order
# sanity: строка действительно собрана в алфавитном порядке ключей.
# Ключи с Password: Alpha < Mid < Password < Zeta (лексикографически).
raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert forward == expected

View file

@ -36,6 +36,42 @@ logger = logging.getLogger(__name__)
_RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
# и маршрут до её edge, а не до нейтрального хоста;
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16
# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08).
_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt"
# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу
# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок
# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот
# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые
# развёртки его не используют.
_PROXY_PROBE_SOURCE: str = "generic"
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
# замерено на проде вместе с релончем, плюс запас на медленный узел.
_PROXY_PROBE_TIMEOUT_S: float = 90.0
# Маркеры отказов, которые сайдкар порождает ИМЕННО из-за прокси (browser/server.py:
# fetch_handler 503 после _ensure_browser → camoufox не поднялся с этим прокси;
# 500 с NS_ERROR_PROXY_* → навигация не прошла через прокси). Всё остальное —
# не про узел (сайдкар недоступен, конфиг сайдкара, пустая страница).
# ponytail: подстроки, а не машинный код отказа — сайдкар не отдаёт поле причины.
# Тест test_2723_browser_probe.py::test_sidecar_error_literals_still_exist сторожит
# расхождение с исходником сайдкара; при следующей правке browser/server.py дешевле
# добавить туда {"fail_kind": "proxy"} и читать его здесь.
_PROXY_FAIL_MARKERS: tuple[str, ...] = (
"browser unavailable (proxy may be down)",
"NS_ERROR_PROXY",
"NS_ERROR_UNKNOWN_PROXY_HOST",
)
# Живая регрессия 2026-08: после скольких подряд провалившихся /fetch ТЕКУЩИЙ session-lease
# считается плохим (бан/сетевая труха) и ОСОЗНАННО меняется один раз (release+acquire), вместо
# того чтобы менять прокси на каждый /fetch как раньше. Camoufox релончится ТОЛЬКО при реальной
@ -78,6 +114,79 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
) from exc
def classify_browser_probe(status: int | None, detail: str) -> str:
"""Кому принадлежит отказ браузерной пробы: узлу, сайдкару или странице (#2723).
Разведение обязательно, иначе повторяется #2686 в третий раз: лежащий сайдкар
пометил бы НЕПРИГОДНЫМИ ВСЕ узлы разом, хотя ни один из них не при чём.
- "proxy" отказ порождён прокси: camoufox не поднялся с ним (503 «browser
unavailable (proxy may be down)») либо навигация не прошла через
него (500 NS_ERROR_PROXY_*). ТОЛЬКО этот исход копит
browser_fail_streak.
- "sidecar" сайдкар недоступен/не сконфигурирован (connect error, таймаут,
503 «no proxy configured», прочие 5xx). Узел не виноват.
- "page" тракт сработал, но ответ не похож на страницу (пустое тело).
Узел не виноват; повод посмотреть на площадку, не на пул.
"""
if status is None:
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
if any(marker in detail for marker in _PROXY_FAIL_MARKERS):
return "proxy"
if status >= 400:
return "sidecar"
return "page"
async def probe_proxy_via_browser(
endpoint: str,
proxy_url: str,
*,
proxy_kind: str = "http",
url: str = _PROXY_PROBE_URL,
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
) -> tuple[bool, str | None, str]:
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
вредна health-checker проверяет узлы, в том числе арендованные, и не должен
конкурировать за lease с боевым прогоном.
Используется `/fetch` (одна навигация), а НЕ `/fetch-json`: последний сначала
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки это уже заметная
нагрузка на неё, ради которой проба и затевалась бы наоборот.
Returns:
(ok, fail_kind, detail). ok=True fail_kind=None. Иначе fail_kind
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail
обрезанный текст для лога.
"""
payload: dict[str, object] = {
"url": url,
"source": _PROXY_PROBE_SOURCE,
"proxy": proxy_url,
"proxy_kind": proxy_kind,
}
try:
async with httpx.AsyncClient(timeout=timeout_s) as client:
resp = await client.post(f"{endpoint}/fetch", json=payload)
except Exception as exc:
detail = f"{type(exc).__name__}: {str(exc)[:200]}"
return False, classify_browser_probe(None, detail), detail
detail = " ".join((resp.text or "").split())[:300]
if resp.status_code != 200:
return False, classify_browser_probe(resp.status_code, detail), detail
try:
html = resp.json().get("html") or ""
except Exception:
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
return True, None, f"html_len={len(html)}"
class BrowserFetcher:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.