feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2) #2654

Merged
bot-backend merged 2 commits from feat/2600-per-source-proxy-health into main 2026-08-05 15:15:01 +00:00
Collaborator

Что было

#2600 п.1 доводил распознанный бан площадкой до пула ГЛОБАЛЬНО: mark_banned выключал узел целиком — enabled=false, disabled_reason='banned:<source>'. Прод-реальность другая: Авито банит IP, а Яндекс/Циан через тот же IP ходят чисто. Итог:

  • один забаненный источник выкидывал живой узел из пула для ВСЕХ источников — пул худел в разы быстрее, чем его пополняют (#2638);
  • состояние не самолечилось: ipify площадку не эмулирует и бана не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) → нужен был ручной PATCH оператора.

Что стало

Бан — свойство ПАРЫ «узел × источник» (новая таблица scrape_proxy_source_bans, миграция 210):

  • mark_banned пишет строку (proxy_id, source, banned_until, ban_count) и БОЛЬШЕ НЕ трогает enabled. Глобальное выключение остаётся только за оператором (#2610) и за авто-disable по серии ТРАНСПОРТНЫХ сбоев (mark_health);
  • acquire(provider) в ОБОИХ заходах (своя affinity и fallback на чужую) отсекает узлы с активным баном по ЭТОМУ provider'у. Узел, забаненный Авито, остаётся первосортным для Яндекса — это и есть суть п.2;
  • защита последнего узла сохранена (advisory-lock + EXISTS, как в п.1), но считается ПО ИСТОЧНИКУ, с учётом активных бан-строк других узлов: если после бана у acquire(source) не останется ни одного кандидата — бан не пишется, WARNING зовёт пополнять пул (#2638). Голодание без прокси хуже, чем работа через забаненный узел;
  • run_proxy_healthcheck в конце сносит бан-строки, истёкшие дольше 7 суток назад (bans_purged в counters);
  • GET /admin/proxies и PATCH /admin/proxies/{id} отдают source_bans: [{source, banned_until, ban_count}] — без этого «узел включён, но не выдаётся» для оператора необъяснимо.

Почему такие TTL / эскалация

  • SOURCE_BAN_BASE_HOURS = 6 — площадки снимают IP-баны за часы, а не минуты (короче → вернём узел под тот же бан и сожжём прогон), но и не за сутки (узел дефицитный).
  • Эскалация base * 2^(ban_count-1): 6 → 12 → 24 → 48 → 72. Узел, который площадка банит раз за разом, отдыхает от неё всё дольше вместо того, чтобы жечь прогоны.
  • SOURCE_BAN_MAX_HOURS = 72 — дольше 3 суток бессмысленно: либо бан снят, либо узел мёртв и его чистит оператор.
  • SOURCE_BAN_PURGE_DAYS = 7: purge НАМЕРЕННО отложенный, а не banned_until < now(). Истёкшая строка уже ничего не блокирует, но хранит ban_count — это единственный носитель памяти об эскалации. Снести раньше — и узел, которого банят каждые сутки, каждый раз начинал бы с 6 часов. Неделя без нового бана = пара чистая, эскалация с нуля (в коде есть комментарий «НЕ оптимизировать»).

Конверсия прод-остатков п.1 (в миграции)

Узлы с enabled=false AND disabled_reason LIKE 'banned:%' новый код не пишет и ничем не снимает — они висели бы выключенными вечно. Миграция конвертирует каждый в 6-часовой per-source бан (source = часть после banned:) и возвращает узел в строй (enabled=true, disabled_reason=NULL). Ручные выключения оператора (disabled_reason без префикса banned:) не трогаются.

Что НЕ входит

  • Пополнение пула новыми прокси (#2638) — здесь только честный учёт того, что есть.
  • Различение fail_kind (timeout / connect_error / http_error) на уровне порогов mark_health — как и было, fail_kind идёт только в логи.
  • Фронтенд: tradein-mvp/frontend листинг /admin/proxies не рендерит (там ProxyHealthCard на /scraper/health), поэтому фронт не трогался.
  • Автоснятие бана по успешной проверке площадкой (health-проба через саму площадку) — бан снимается только по времени.

Как проверять на проде

ssh gendesign
# 1) миграция применилась + конверсия сработала
docker exec tradein-postgres psql -U <user> -d tradein -c \
  "SELECT * FROM scrape_proxy_source_bans ORDER BY banned_until DESC;"
docker exec tradein-postgres psql -U <user> -d tradein -c \
  "SELECT id, enabled, disabled_reason FROM scrape_proxies WHERE disabled_reason LIKE 'banned:%';"
#    ожидание: вторая выборка ПУСТА, узлы из неё вернулись enabled=true,
#    а их баны лежат в первой (reason='migrated from disabled_reason (210)')

# 2) видимость для оператора
docker exec tradein-backend curl -s -H "X-Authenticated-User: admin" \
  localhost:8000/api/v1/admin/proxies
#    ожидание: у каждого узла поле source_bans (пустое, если банов нет)

# 3) живой бан (после ближайшего сбора Авито)
docker logs tradein-scraper 2>&1 | grep "BANNED by source"
#    ожидание: "узел снят с выдачи ТОЛЬКО для этого источника до <ts> (ban_count=N)"
#    и НЕТ enabled=false у этого узла; сбор другого источника через него продолжается

# 4) purge
docker logs tradein-scraper 2>&1 | grep "healthcheck done"   # bans_purged=N

Test plan

  • pytest tests/services/test_proxy_pool.py — 50 passed (новые: ban-строка вместо глобального disable, эскалация + потолок, acquire по своему/чужому source, истёкший бан, защита последнего узла с учётом активных банов, purge)
  • pytest tests/test_admin_proxies.py — 17 passed (добавлен source_bans в листинге)
  • полный pytest tests — 3316 passed, 1 failed (test_search_api.py::test_search_cache_hit — падает и на чистом main в том же окружении, к прокси отношения не имеет)
  • прод-верификация по шагам выше после деплоя

Refs #2600

## Что было `#2600 п.1` доводил распознанный бан площадкой до пула ГЛОБАЛЬНО: `mark_banned` выключал узел целиком — `enabled=false`, `disabled_reason='banned:<source>'`. Прод-реальность другая: Авито банит IP, а Яндекс/Циан через тот же IP ходят чисто. Итог: - один забаненный источник выкидывал живой узел из пула для ВСЕХ источников — пул худел в разы быстрее, чем его пополняют (#2638); - состояние не самолечилось: ipify площадку не эмулирует и бана не видит, а non-NULL `disabled_reason` блокирует авто-воскрешение (#2610) → нужен был ручной PATCH оператора. ## Что стало Бан — свойство ПАРЫ «узел × источник» (новая таблица `scrape_proxy_source_bans`, миграция 210): - `mark_banned` пишет строку `(proxy_id, source, banned_until, ban_count)` и БОЛЬШЕ НЕ трогает `enabled`. Глобальное выключение остаётся только за оператором (#2610) и за авто-disable по серии ТРАНСПОРТНЫХ сбоев (`mark_health`); - `acquire(provider)` в ОБОИХ заходах (своя affinity и fallback на чужую) отсекает узлы с активным баном по ЭТОМУ provider'у. Узел, забаненный Авито, остаётся первосортным для Яндекса — это и есть суть п.2; - защита последнего узла сохранена (advisory-lock + EXISTS, как в п.1), но считается ПО ИСТОЧНИКУ, с учётом активных бан-строк других узлов: если после бана у `acquire(source)` не останется ни одного кандидата — бан не пишется, WARNING зовёт пополнять пул (#2638). Голодание без прокси хуже, чем работа через забаненный узел; - `run_proxy_healthcheck` в конце сносит бан-строки, истёкшие дольше 7 суток назад (`bans_purged` в counters); - `GET /admin/proxies` и `PATCH /admin/proxies/{id}` отдают `source_bans: [{source, banned_until, ban_count}]` — без этого «узел включён, но не выдаётся» для оператора необъяснимо. ### Почему такие TTL / эскалация - `SOURCE_BAN_BASE_HOURS = 6` — площадки снимают IP-баны за часы, а не минуты (короче → вернём узел под тот же бан и сожжём прогон), но и не за сутки (узел дефицитный). - Эскалация `base * 2^(ban_count-1)`: 6 → 12 → 24 → 48 → 72. Узел, который площадка банит раз за разом, отдыхает от неё всё дольше вместо того, чтобы жечь прогоны. - `SOURCE_BAN_MAX_HOURS = 72` — дольше 3 суток бессмысленно: либо бан снят, либо узел мёртв и его чистит оператор. - `SOURCE_BAN_PURGE_DAYS = 7`: purge НАМЕРЕННО отложенный, а не `banned_until < now()`. Истёкшая строка уже ничего не блокирует, но хранит `ban_count` — это единственный носитель памяти об эскалации. Снести раньше — и узел, которого банят каждые сутки, каждый раз начинал бы с 6 часов. Неделя без нового бана = пара чистая, эскалация с нуля (в коде есть комментарий «НЕ оптимизировать»). ### Конверсия прод-остатков п.1 (в миграции) Узлы с `enabled=false AND disabled_reason LIKE 'banned:%'` новый код не пишет и ничем не снимает — они висели бы выключенными вечно. Миграция конвертирует каждый в 6-часовой per-source бан (`source` = часть после `banned:`) и возвращает узел в строй (`enabled=true, disabled_reason=NULL`). Ручные выключения оператора (`disabled_reason` без префикса `banned:`) не трогаются. ## Что НЕ входит - Пополнение пула новыми прокси (#2638) — здесь только честный учёт того, что есть. - Различение fail_kind (timeout / connect_error / http_error) на уровне порогов `mark_health` — как и было, `fail_kind` идёт только в логи. - Фронтенд: `tradein-mvp/frontend` листинг `/admin/proxies` не рендерит (там `ProxyHealthCard` на `/scraper/health`), поэтому фронт не трогался. - Автоснятие бана по успешной проверке площадкой (health-проба через саму площадку) — бан снимается только по времени. ## Как проверять на проде ```bash ssh gendesign # 1) миграция применилась + конверсия сработала docker exec tradein-postgres psql -U <user> -d tradein -c \ "SELECT * FROM scrape_proxy_source_bans ORDER BY banned_until DESC;" docker exec tradein-postgres psql -U <user> -d tradein -c \ "SELECT id, enabled, disabled_reason FROM scrape_proxies WHERE disabled_reason LIKE 'banned:%';" # ожидание: вторая выборка ПУСТА, узлы из неё вернулись enabled=true, # а их баны лежат в первой (reason='migrated from disabled_reason (210)') # 2) видимость для оператора docker exec tradein-backend curl -s -H "X-Authenticated-User: admin" \ localhost:8000/api/v1/admin/proxies # ожидание: у каждого узла поле source_bans (пустое, если банов нет) # 3) живой бан (после ближайшего сбора Авито) docker logs tradein-scraper 2>&1 | grep "BANNED by source" # ожидание: "узел снят с выдачи ТОЛЬКО для этого источника до <ts> (ban_count=N)" # и НЕТ enabled=false у этого узла; сбор другого источника через него продолжается # 4) purge docker logs tradein-scraper 2>&1 | grep "healthcheck done" # bans_purged=N ``` ## Test plan - [x] `pytest tests/services/test_proxy_pool.py` — 50 passed (новые: ban-строка вместо глобального disable, эскалация + потолок, acquire по своему/чужому source, истёкший бан, защита последнего узла с учётом активных банов, purge) - [x] `pytest tests/test_admin_proxies.py` — 17 passed (добавлен `source_bans` в листинге) - [x] полный `pytest tests` — 3316 passed, 1 failed (`test_search_api.py::test_search_cache_hit` — падает и на чистом main в том же окружении, к прокси отношения не имеет) - [ ] прод-верификация по шагам выше после деплоя Refs #2600
bot-backend added 1 commit 2026-08-05 12:15:57 +00:00
feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
964867a943
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.

Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.

Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.

Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.

Refs #2600
Light1YT added 1 commit 2026-08-05 12:45:20 +00:00
fix(tradein/proxy): backup-узел должен быть пригоден + рычаг снятия бана (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m45s
00bc07a55f
Правки по deep-review PR #2654.

MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.

MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).

LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.

LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.

LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.

Refs #2600
bot-backend merged commit fcaa7c6364 into main 2026-08-05 15:15:01 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2654
No description provided.