fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1) #2653

Merged
bot-backend merged 1 commit from fix/tradein-ban-signal-to-pool into main 2026-08-05 11:37:36 +00:00
Collaborator

Summary

#2600 п.1 (предусловие п.2). Корень: mark_health(ok) выставляется в __exit__ контекст-менеджера и даёт ok=False только при исключении из блока. Мягкий бан приходит валидным HTTP 200 с заглушкой (Авито «Доступ ограничен: проблема с IP», капча Циана, gate-заглушка Яндекса, QRATOR Домклика) и распознаётся ПОЗЖЕ — при разборе HTML, уже вне блока. Итог: забаненный прокси получал ok=True, consecutive_fails обнулялся, узел выдавали снова.

Решение: BrowserFetcher.report_ban() помечает текущий sticky-lease (#2640) → proxy_pool.mark_banned() выключает узел с disabled_reason='banned:<source>' (колонка из #2610 — ipify-проба такой узел не воскрешает, иначе флаппинг).

Тонкость инструментации: Avito размечен на raise-сайтах, а не в __aexit__pipeline.py присваивает scraper._browser напрямую и в контекст-менеджер не заходит, централизованный хук пропустил бы весь этот путь (тот же класс ловушки, что чинили в #2634). Cian/Yandex проверяют свои счётчики в __aexit__ до релиза lease. Curl-путь получил маркер ProxyBanError (подмешан в Blocked-исключения; AvitoRateLimitedError намеренно НЕ включён — может быть нашим таймаутом).

Две защиты, добавленные по deep-review (🟠 HIGH ×2)

Ложный бан. У Яндекса _http_get глотал исключения fetch() и отдавал status_code=0 — этот случай считался в тот же счётчик, что настоящая капча. Живой путь POST /admin/scrape {"sources":["yandex"]} делает ровно одну попытку → 1/1 = 100% → здоровый узел выключался бы как забаненный. Добавлен признак transport_error (счётчик трогают только контентные провалы — Циан так делал изначально, зеркалим) + порог attempts >= 3 на обоих триггерах. Проверено ревью: боевой sweep делает ≥4 попытки на инстанс (cian: 4 room-бакета × страницы, yandex: room×price-комбо) — детект настоящего бана не страдает, а одиночный админский прогон структурно защищён. Mixed-случай (транспорт → контент) покрыт отдельным флагом и тестом.

Выкос пула. mark_banned не выключает узел, если он последний достижимый для источника — EXISTS зеркалит логику acquire() с учётом affinity (domclick-узел не считается запасным для avito/cian/yandex). Плюс pg_advisory_xact_lock: одностроч­ный UPDATE не атомарен поперёк строк, два параллельных бана разных узлов проходили мимо защиты и могли обнулить пул — без самолечения (disabled_reason запрещает ipify-воскрешение). Ключ проверен на коллизии со всеми advisory-локами репо; дедлока с FOR UPDATE SKIP LOCKED в acquire() нет.

Три вида отказа остались различимы: бан площадки → mark_banned; нет прокси → NoProxyAvailableError (#2616); сетевой сбой → обычный mark_health(ok=False).

Test plan

  • 3305 passed (1 known pre-existing 401), ruff чисто
  • Red/green проверены ревьюером стэшем (20 тестов красные на до-патче; дельта-тесты — отдельно)
  • Покрыто: транспорт≠бан, floor, mixed-случай, защита последнего узла (вкл. dedicated-affinity и quarantined-кандидата), advisory-lock берётся даже когда апдейт — no-op
  • Post-deploy: наблюдать первый настоящий бан — узел уходит в disabled_reason='banned:*', пул не обнуляется

Review

deep-code-reviewer: 🟠 HIGH ×2 → оба закрыты в этом же PR; повторный code-review дельты: APPROVE (проверил, что floor не ослепляет детект, коллизии ключей, отсутствие дедлока, red/green дельты).

Не входит (осознанно): п.2 #2600 (per-source banned_until), wiring proxy_provider в domclick SERP (#2160 P4), IMVAuthError в ban-сигнал.

Refs #2600

## Summary **#2600 п.1** (предусловие п.2). Корень: `mark_health(ok)` выставляется в `__exit__` контекст-менеджера и даёт `ok=False` только при исключении **из блока**. Мягкий бан приходит валидным **HTTP 200 с заглушкой** (Авито «Доступ ограничен: проблема с IP», капча Циана, gate-заглушка Яндекса, QRATOR Домклика) и распознаётся ПОЗЖЕ — при разборе HTML, уже вне блока. Итог: забаненный прокси получал `ok=True`, `consecutive_fails` обнулялся, узел выдавали снова. **Решение**: `BrowserFetcher.report_ban()` помечает текущий sticky-lease (#2640) → `proxy_pool.mark_banned()` выключает узел с `disabled_reason='banned:<source>'` (колонка из #2610 — ipify-проба такой узел не воскрешает, иначе флаппинг). **Тонкость инструментации**: Avito размечен **на raise-сайтах**, а не в `__aexit__` — `pipeline.py` присваивает `scraper._browser` напрямую и в контекст-менеджер не заходит, централизованный хук пропустил бы весь этот путь (тот же класс ловушки, что чинили в #2634). Cian/Yandex проверяют свои счётчики в `__aexit__` **до** релиза lease. Curl-путь получил маркер `ProxyBanError` (подмешан в Blocked-исключения; `AvitoRateLimitedError` намеренно НЕ включён — может быть нашим таймаутом). ## Две защиты, добавленные по deep-review (🟠 HIGH ×2) **Ложный бан.** У Яндекса `_http_get` глотал исключения `fetch()` и отдавал `status_code=0` — этот случай считался в тот же счётчик, что настоящая капча. Живой путь `POST /admin/scrape {"sources":["yandex"]}` делает **ровно одну** попытку → 1/1 = 100% → здоровый узел выключался бы как забаненный. Добавлен признак `transport_error` (счётчик трогают только контентные провалы — Циан так делал изначально, зеркалим) + порог `attempts >= 3` на обоих триггерах. Проверено ревью: боевой sweep делает ≥4 попытки на инстанс (cian: 4 room-бакета × страницы, yandex: room×price-комбо) — **детект настоящего бана не страдает**, а одиночный админский прогон структурно защищён. Mixed-случай (транспорт → контент) покрыт отдельным флагом и тестом. **Выкос пула.** `mark_banned` не выключает узел, если он последний достижимый для источника — EXISTS зеркалит логику `acquire()` **с учётом affinity** (domclick-узел не считается запасным для avito/cian/yandex). Плюс `pg_advisory_xact_lock`: одностроч­ный UPDATE не атомарен поперёк строк, два параллельных бана разных узлов проходили мимо защиты и могли обнулить пул — **без самолечения** (`disabled_reason` запрещает ipify-воскрешение). Ключ проверен на коллизии со всеми advisory-локами репо; дедлока с `FOR UPDATE SKIP LOCKED` в `acquire()` нет. **Три вида отказа остались различимы**: бан площадки → `mark_banned`; нет прокси → `NoProxyAvailableError` (#2616); сетевой сбой → обычный `mark_health(ok=False)`. ## Test plan - [x] 3305 passed (1 known pre-existing 401), ruff чисто - [x] Red/green проверены ревьюером стэшем (20 тестов красные на до-патче; дельта-тесты — отдельно) - [x] Покрыто: транспорт≠бан, floor, mixed-случай, защита последнего узла (вкл. dedicated-affinity и quarantined-кандидата), advisory-lock берётся даже когда апдейт — no-op - [ ] Post-deploy: наблюдать первый настоящий бан — узел уходит в `disabled_reason='banned:*'`, пул не обнуляется ## Review deep-code-reviewer: 🟠 HIGH ×2 → **оба закрыты в этом же PR**; повторный code-review дельты: **✅ APPROVE** (проверил, что floor не ослепляет детект, коллизии ключей, отсутствие дедлока, red/green дельты). **Не входит** (осознанно): п.2 #2600 (per-source `banned_until`), wiring `proxy_provider` в domclick SERP (#2160 P4), `IMVAuthError` в ban-сигнал. Refs #2600
bot-backend added 1 commit 2026-08-05 11:34:06 +00:00
fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 2m44s
d890e5cbe1
mark_health(ok) выставляется в __exit__ контекст-менеджера и ставит
ok=False только если из блока вылетело исключение. Мягкий бан приходит
валидным HTTP 200 с заглушкой (Авито «Доступ ограничен», капча Циана,
gate-заглушка Яндекса, QRATOR Домклика) и распознаётся ПОЗЖЕ, при разборе
HTML — уже вне блока. Итог: забаненный прокси получал ok=True,
consecutive_fails обнулялся, узел выдавали снова.

BrowserFetcher.report_ban() помечает текущий sticky-lease (#2640);
proxy_pool.mark_banned() выключает узел с disabled_reason='banned:<source>'
(колонка из #2610 — ipify-проба такой узел не воскрешает, иначе флаппинг).

Avito инструментирован на raise-сайтах, а не в __aexit__: pipeline
присваивает scraper._browser напрямую и не заходит в контекст-менеджер —
централизованный хук пропустил бы весь этот путь. Cian/Yandex проверяют
свои счётчики в __aexit__ ДО релиза lease. Curl-путь получил маркер
ProxyBanError (подмешан в Blocked-исключения; RateLimited намеренно нет —
он может быть нашим таймаутом).

Защита от выкоса пула: mark_banned не выключает узел, если он последний
достижимый для источника (EXISTS зеркалит логику acquire с учётом
affinity — domclick-узел не считается запасным для avito/cian/yandex).
pg_advisory_xact_lock сериализует проверку+апдейт: одностроч­ный UPDATE не
атомарен поперёк строк, два параллельных бана разных узлов могли пройти
мимо защиты и выкосить пул без самолечения.

Защита от ложного бана (deep-review): у Яндекса транспортные сбои
(_http_get глотал исключения и отдавал status_code=0) считались в тот же
счётчик, что настоящая капча — добавлен признак transport_error, счётчик
трогают только контентные провалы (Циан так делал изначально). Плюс порог
attempts>=3 на обоих триггерах: админский одиночный прогон делает ровно
одну попытку, и 1/1=100% выключал бы здоровый узел. Боевой sweep делает
>=4 попытки, детект настоящего бана не страдает.

Refs #2600
bot-backend merged commit d362b16d7c into main 2026-08-05 11:37:36 +00:00
bot-backend deleted branch fix/tradein-ban-signal-to-pool 2026-08-05 11:37:36 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2653
No description provided.