tradein: прокси-пул — здоровье глобальное, бан на одном источнике выбрасывает прокси отовсюду (и обычно не засчитывается) #2600

Closed
opened 2026-07-31 20:28:02 +00:00 by lekss361 · 5 comments
Owner

Поднято владельцем 2026-07-31: «если прокси забанен на Авито — почему не отдать его Яндексу, а Авито дать тот, что работал у Яндекса». Разбор показал: такой логики нет, а имеющаяся работает в противоположную сторону.

Код читан на forgejo/main @ a596caf5, прод-данные — tradein-postgres на 20:00.

Как устроено сейчас

Пул есть и в проде включён (USE_PROXY_POOL_CURL=true, USE_PROXY_POOL_BROWSER=true), таблица scrape_proxies, сервис app/services/proxy_pool.py.

  1. provider_affinity статическая. Значения avito/cian/yandex/domclick/generic/any заданы, но ни одного UPDATE ... provider_affinity в коде нет — только ручной админский bulk_upsert_proxies (app/api/v1/admin.py:2702). Система сама прокси не переназначает.

  2. Здоровье — один общий счётчик. acquire() фильтрует глобально: enabled AND consecutive_fails < 3. Засчитанный бан выкидывает прокси из всех источников сразу, а не понижает для одного — противоположность запрошенному.

  3. Но бан обычно не засчитывается. Мягкий бан Авито — страница-заглушка «Доступ ограничен: проблема с IP» с HTTP 200 (providers/avito/serp.py:224), не 403. В browser_fetcher.py::_pool_proxy и providers/_proxy.py::curl_proxy_url ok=False ставится только если из блока вылетело исключение, а raise_for_status() проверяет ответ нашего tradein-browser, не Авито. Бан распознаётся позже, при разборе HTML — вне контекст-менеджера. Забаненный прокси получает mark_health(ok=True), счётчик сбрасывается в 0, его выдают снова.

  4. Счётчик крутит только ipify-проба run_proxy_healthcheck (расписание id 142, раз в 30 мин). Она берёт WHERE enabled → после авто-disable на 5 фейлах прокси больше не проверяется и сам не возвращается. Храповик в одну сторону.

Прод

Из 8 прокси 5 выключены с consecutive_fails=5. Живых три: id 1 (affinity domclick), id 10 и 11 (affinity any). Авито, Циан и Яндекс делят два прокси при эксклюзивной аренде (leased_by, FOR UPDATE SKIP LOCKED) — третий источник при параллельных прогонах остаётся без прокси.

Фолбэк воскрешает приговорённых: при пустом acquire() код берёт env-прокси, а это ровно выключенные узлы —

env узел в пуле
AVITO_PROXY_URL ard.mobileproxy.space:1055 id 4, выключен, 5 фейлов
CIAN_PROXY_URL ha.mobileproxy.space:2014 id 2, выключен, 5 фейлов
YANDEX_PROXY_URL aup.mobileproxy.space:1080 id 5, выключен, 5 фейлов

Отсюда 8 прогонов banned за 48 часов. asocks-mobile-1 был жив в 16:38 и к 20:00 выключен с 5 фейлами — парк расходуется прямо сейчас.

Что делать

Порядок важен, п.1 — предусловие п.2.

  1. Довести сигнал бана до пула. Точка распознавания заглушки в serp.py знает породившую её аренду, но пулу не сообщает. Без этого учить пул нечему.
  2. Здоровье по паре «прокси + источник» — таблица с banned_until на пару, acquire(provider) фильтрует по ней. Бан на Авито понижает прокси только для Авито, для Яндекса он остаётся первосортным. Это и есть запрошенное поведение.
  3. Фолбэк не должен брать выключенные узлы — при пустом пуле предпочесть здоровый прокси чужой affinity, а не приговорённый env-узел.
  4. Авто-восстановление — проверять и выключенные, реже, включать обратно при успехе. Иначе убыль монотонна: 5 из 8 уже потеряны.

Ротация IP по бану — отдельный рычаг, ждёт номера портов ASocks (у asocks-узлов rotate_url пуст, у mobileproxy заполнен; лимит 3 ротации в сутки на порт).

Дробление: п.1+3 — один PR без новой схемы, даёт эффект сразу. п.2 — отдельный PR с миграцией. п.4 — мелкий, к п.1.

Связано: #2161, #2162, #2163, #2164.

Поднято владельцем 2026-07-31: «если прокси забанен на Авито — почему не отдать его Яндексу, а Авито дать тот, что работал у Яндекса». Разбор показал: такой логики нет, а имеющаяся работает в противоположную сторону. Код читан на `forgejo/main` @ `a596caf5`, прод-данные — `tradein-postgres` на 20:00. ## Как устроено сейчас Пул есть и в проде включён (`USE_PROXY_POOL_CURL=true`, `USE_PROXY_POOL_BROWSER=true`), таблица `scrape_proxies`, сервис `app/services/proxy_pool.py`. 1. **`provider_affinity` статическая.** Значения `avito/cian/yandex/domclick/generic/any` заданы, но ни одного `UPDATE ... provider_affinity` в коде нет — только ручной админский `bulk_upsert_proxies` (`app/api/v1/admin.py:2702`). Система сама прокси не переназначает. 2. **Здоровье — один общий счётчик.** `acquire()` фильтрует глобально: `enabled AND consecutive_fails < 3`. Засчитанный бан выкидывает прокси **из всех источников сразу**, а не понижает для одного — противоположность запрошенному. 3. **Но бан обычно не засчитывается.** Мягкий бан Авито — страница-заглушка «Доступ ограничен: проблема с IP» с HTTP 200 (`providers/avito/serp.py:224`), не 403. В `browser_fetcher.py::_pool_proxy` и `providers/_proxy.py::curl_proxy_url` `ok=False` ставится только если из блока вылетело исключение, а `raise_for_status()` проверяет ответ нашего `tradein-browser`, не Авито. Бан распознаётся позже, при разборе HTML — вне контекст-менеджера. Забаненный прокси получает `mark_health(ok=True)`, счётчик сбрасывается в 0, его выдают снова. 4. **Счётчик крутит только ipify-проба** `run_proxy_healthcheck` (расписание id 142, раз в 30 мин). Она берёт `WHERE enabled` → после авто-disable на 5 фейлах прокси больше не проверяется и сам не возвращается. Храповик в одну сторону. ## Прод Из 8 прокси **5 выключены** с `consecutive_fails=5`. Живых три: id 1 (affinity `domclick`), id 10 и 11 (affinity `any`). Авито, Циан и Яндекс делят **два** прокси при эксклюзивной аренде (`leased_by`, `FOR UPDATE SKIP LOCKED`) — третий источник при параллельных прогонах остаётся без прокси. Фолбэк воскрешает приговорённых: при пустом `acquire()` код берёт env-прокси, а это ровно выключенные узлы — | env | узел | в пуле | |---|---|---| | `AVITO_PROXY_URL` | `ard.mobileproxy.space:1055` | id 4, выключен, 5 фейлов | | `CIAN_PROXY_URL` | `ha.mobileproxy.space:2014` | id 2, выключен, 5 фейлов | | `YANDEX_PROXY_URL` | `aup.mobileproxy.space:1080` | id 5, выключен, 5 фейлов | Отсюда 8 прогонов `banned` за 48 часов. `asocks-mobile-1` был жив в 16:38 и к 20:00 выключен с 5 фейлами — парк расходуется прямо сейчас. ## Что делать Порядок важен, п.1 — предусловие п.2. 1. **Довести сигнал бана до пула.** Точка распознавания заглушки в `serp.py` знает породившую её аренду, но пулу не сообщает. Без этого учить пул нечему. 2. **Здоровье по паре «прокси + источник»** — таблица с `banned_until` на пару, `acquire(provider)` фильтрует по ней. Бан на Авито понижает прокси только для Авито, для Яндекса он остаётся первосортным. Это и есть запрошенное поведение. 3. **Фолбэк не должен брать выключенные узлы** — при пустом пуле предпочесть здоровый прокси чужой affinity, а не приговорённый env-узел. 4. **Авто-восстановление** — проверять и выключенные, реже, включать обратно при успехе. Иначе убыль монотонна: 5 из 8 уже потеряны. Ротация IP по бану — отдельный рычаг, ждёт номера портов ASocks (у asocks-узлов `rotate_url` пуст, у mobileproxy заполнен; лимит 3 ротации в сутки на порт). Дробление: п.1+3 — один PR без новой схемы, даёт эффект сразу. п.2 — отдельный PR с миграцией. п.4 — мелкий, к п.1. Связано: #2161, #2162, #2163, #2164.
Author
Owner

Владелец прислал номера портов ASocks — блокер снят частично, плюс живая проба вскрыла, что состояние пула перевёрнуто относительно реальности.

Сопоставление портов

порт ASocks адрес запись в пуле
223610715 212.8.249.134:10423 id 1 asocks-residential-1 (affinity domclick)
225031312 190.2.145.131:10313 id 9 asocks-mobile-1
231878029 175.110.115.153:10492 id 10 asocks-mobile-2
231878030 109.236.82.42:11048 id 11 asocks-mobile-3

Лимит — 3 ротации в сутки на порт.

Эндпоинт: брать документированный, а не дашбордный

Присланный POST /unlimited-proxy/{id}/refresh-ip — ручка веб-кабинета, авторизуется сессией браузера. Проба с прода: 401 {"success":false,"message":"Unauthenticated"} (ротация не потрачена).

Для сервера годится документированный публичный API:

GET https://api.asocks.com/v2/proxy/refresh/{portId}?apiKey=КЛЮЧ
→ 200 {"success": true}   (коды 200/401/404)

Долгоживущий ключ вместо сессии. Ключ ещё нужен от владельца — в env прода (.env.runtime), не в коммит и не в колонку rotate_url.

🔴 Пул рассинхронизирован с реальностью — доказано пробой

Прогнал ipify через каждый прокси ASocks напрямую из tradein-scraper:

прокси состояние в пуле реальность
id 9 asocks-mobile-1 выключен, 5 фейлов работает, exit 5.227.31.243
id 11 asocks-mobile-3 выключен, 5 фейлов работает, exit 178.71.241.249
id 10 asocks-mobile-2 enabled, 0 фейлов 502 Bad Gateway
id 1 asocks-residential-1 enabled, 0 фейлов работает, exit 45.140.53.184

Единственный прокси, который пул считал здоровым, — сломан; два рабочих лежали выключенными. Это не гипотеза из первого разбора, а измеренный факт, и он подтверждает пункт 4 (авто-восстановление) как не менее важный, чем пункт 2.

Механика: run_proxy_healthcheck берёт WHERE enabled, поэтому после авто-disable на 5 фейлах узел больше не проверяется никогда. Транзиентный сбой становится вечным приговором.

Отдельно про скорость расхода: id 11 был enabled, 0 fails в 22:10 и disabled, 5 fails к 04:30 — сгорел за ночь на транзиентных ошибках, при этом физически исправен.

Сделано сейчас

UPDATE scrape_proxies SET enabled = true, consecutive_fails = 0 WHERE id IN (9, 11) — оба проверены живой пробой до включения. Пул: было 2 «здоровых» (из них 1 реально сломан), стало 4 (из них 3 реально рабочих). Для avito/cian/yandex доступных стало 3 вместо фактического нуля.

Это ручное лечение симптома, причина (пункт 4) не устранена.

Уточнение приоритетов

Пункт 4 (авто-восстановление) поднимается с «мелкий, к пункту 1» до самостоятельного и срочного: без него пул деградирует до нуля за считанные дни независимо от всего остального. Порядок теперь:

  1. Авто-восстановление — проверять и выключенных, реже; включать обратно при успехе. Плюс не считать транзиентную сетевую ошибку тем же, чем бан.
  2. Довести сигнал бана до пула — сейчас бан приходит страницей с кодом 200 и до mark_health не доходит.
  3. Фолбэк не должен брать выключенные узлы.
  4. Здоровье по паре «прокси + источник» — исходный запрос владельца; имеет смысл после 1-3.
  5. Ротация по бану через документированный эндпоинт, счётчик 3/сутки на порт, только на подтверждённый бан.
Владелец прислал номера портов ASocks — блокер снят частично, плюс живая проба вскрыла, что состояние пула **перевёрнуто относительно реальности**. ## Сопоставление портов | порт ASocks | адрес | запись в пуле | |---|---|---| | 223610715 | `212.8.249.134:10423` | id 1 `asocks-residential-1` (affinity `domclick`) | | 225031312 | `190.2.145.131:10313` | id 9 `asocks-mobile-1` | | 231878029 | `175.110.115.153:10492` | id 10 `asocks-mobile-2` | | 231878030 | `109.236.82.42:11048` | id 11 `asocks-mobile-3` | Лимит — 3 ротации в сутки на порт. ## Эндпоинт: брать документированный, а не дашбордный Присланный `POST /unlimited-proxy/{id}/refresh-ip` — ручка веб-кабинета, авторизуется сессией браузера. Проба с прода: **401 `{"success":false,"message":"Unauthenticated"}`** (ротация не потрачена). Для сервера годится документированный публичный API: ``` GET https://api.asocks.com/v2/proxy/refresh/{portId}?apiKey=КЛЮЧ → 200 {"success": true} (коды 200/401/404) ``` Долгоживущий ключ вместо сессии. **Ключ ещё нужен от владельца** — в env прода (`.env.runtime`), не в коммит и не в колонку `rotate_url`. ## 🔴 Пул рассинхронизирован с реальностью — доказано пробой Прогнал ipify через каждый прокси ASocks напрямую из `tradein-scraper`: | прокси | состояние в пуле | реальность | |---|---|---| | id 9 `asocks-mobile-1` | выключен, 5 фейлов | **работает**, exit 5.227.31.243 | | id 11 `asocks-mobile-3` | выключен, 5 фейлов | **работает**, exit 178.71.241.249 | | id 10 `asocks-mobile-2` | **enabled, 0 фейлов** | **502 Bad Gateway** | | id 1 `asocks-residential-1` | enabled, 0 фейлов | работает, exit 45.140.53.184 | Единственный прокси, который пул считал здоровым, — сломан; два рабочих лежали выключенными. Это не гипотеза из первого разбора, а измеренный факт, и он подтверждает пункт 4 (авто-восстановление) как не менее важный, чем пункт 2. Механика: `run_proxy_healthcheck` берёт `WHERE enabled`, поэтому после авто-disable на 5 фейлах узел больше не проверяется никогда. Транзиентный сбой становится вечным приговором. Отдельно про скорость расхода: id 11 был `enabled, 0 fails` в 22:10 и `disabled, 5 fails` к 04:30 — сгорел за ночь на транзиентных ошибках, при этом физически исправен. ## Сделано сейчас `UPDATE scrape_proxies SET enabled = true, consecutive_fails = 0 WHERE id IN (9, 11)` — оба проверены живой пробой до включения. Пул: было 2 «здоровых» (из них 1 реально сломан), стало 4 (из них 3 реально рабочих). Для avito/cian/yandex доступных стало 3 вместо фактического нуля. Это ручное лечение симптома, причина (пункт 4) не устранена. ## Уточнение приоритетов Пункт 4 (авто-восстановление) поднимается с «мелкий, к пункту 1» до **самостоятельного и срочного**: без него пул деградирует до нуля за считанные дни независимо от всего остального. Порядок теперь: 1. **Авто-восстановление** — проверять и выключенных, реже; включать обратно при успехе. Плюс не считать транзиентную сетевую ошибку тем же, чем бан. 2. **Довести сигнал бана до пула** — сейчас бан приходит страницей с кодом 200 и до `mark_health` не доходит. 3. **Фолбэк не должен брать выключенные узлы.** 4. **Здоровье по паре «прокси + источник»** — исходный запрос владельца; имеет смысл после 1-3. 5. **Ротация по бану** через документированный эндпоинт, счётчик 3/сутки на порт, только на подтверждённый бан.
Author
Owner

Пункт 1 (авто-восстановление) и пункт 3 (голод при занятых своих узлах) закрыты — PR #2609 смержен (5b2d6807).

Что вошло

  • Проверка здоровья опрашивает теперь и выключенные узлы — реже (константа DISABLED_RECHECK_MINUTES = 60), и возвращает их в строй при успешной пробе. Счётчик revived в результате прогона. Это лечит первопричину: раньше выборка шла WHERE enabled, поэтому авто-выключенный узел не проверялся больше никогда.
  • acquire() при отсутствии свободных своих берёт узел чужой affinity вторым заходом, с предупреждением в лог.
  • Защита последнего узла выделенной affinity — добавлена после первого раунда ревью. Без неё голод не исчезал, а переезжал: domclick это ровно один выделенный узел (id 1), вырезанный из общего пула потому, что QRATOR банит всё, кроме этого чистого residential-адреса (обоснование в миграции 173_scrape_proxies_add_domclick_affinity.sql). Авито/Циан/Яндекс могли бы его забрать и оставить Домклик без прокси.
  • Тип сетевой ошибки классифицируется и попадает в лог. Полноценное разделение «транзиент против бана» не делалось — требует новой колонки, обосновано в PR.

Проверки, которые стоит отметить

Семантика блокировок проверена эмпирически, а не рассуждением: ревьюер держал открытую транзакцию с FOR UPDATE на одной строке и параллельной сессией пробовал FOR UPDATE NOWAIT на той, что видна только внутри коррелированного подзапроса — прошло мгновенно, тогда как на первой строке честно упало с ошибкой блокировки. Вывод: алиас подзапроса под внешний FOR UPDATE не попадает, патологии нет.

Фальсификация проведена подменой SQL напрямую в файле (а не через git — иначе получается бесполезная ошибка импорта константы): убрал условие защиты — тест упал; убрал enabled = true — упал тест реанимации.

Отдельно: автор сам нашёл и починил второй экземпляр методологической проблемы, на который я указал для первого — его собственный новый тест проходил бы и против незащищённого SQL, потому что мок реализовывал защиту в Python независимо от запроса. Тест проверял сам себя.

Мелкая находка, не блокер

proxy_pool.py:174-180 — «бэкап» в EXISTS-подзапросе засчитывается только по other.enabled, без учёта consecutive_fails. Сейчас не проявляется (у domclick ровно один узел, EXISTS честно даёт ноль). Но если у выделенной affinity появится второй узел и он окажется в карантине (3-4 фейла, ещё enabled, но acquire его уже не выдаёт), подзапрос сочтёт его валидным бэкапом и отдаст последний реально рабочий узел чужому провайдеру. Лечится одним условием AND other.consecutive_fails < CAST(:max_fails AS integer) — стоит закрыть при следующем касании файла.

Статус пунктов

  1. Авто-восстановление — сделано.
  2. Довести сигнал бана до пула — не сделано, самый рискованный пункт (правка в горячем пути скрапера).
  3. Фолбэк не берёт выключенные + защита последнего узла — сделано.
  4. Здоровье по паре «прокси + источник» — исходный запрос владельца, имеет смысл после пункта 2.
  5. 🔄 Ротация по бану — PR #2611 на глубоком ревью. Механизм и ручной запуск; автоматический триггер невозможен до пункта 2.

Плюс отдельно заведена #2610 — пул не отличает ручное выключение от авто-отключения.

Пункт 1 (авто-восстановление) и пункт 3 (голод при занятых своих узлах) закрыты — **PR #2609 смержен** (`5b2d6807`). ## Что вошло - Проверка здоровья опрашивает теперь и выключенные узлы — реже (константа `DISABLED_RECHECK_MINUTES = 60`), и возвращает их в строй при успешной пробе. Счётчик `revived` в результате прогона. Это лечит первопричину: раньше выборка шла `WHERE enabled`, поэтому авто-выключенный узел не проверялся больше никогда. - `acquire()` при отсутствии свободных своих берёт узел чужой affinity вторым заходом, с предупреждением в лог. - **Защита последнего узла выделенной affinity** — добавлена после первого раунда ревью. Без неё голод не исчезал, а переезжал: `domclick` это ровно один выделенный узел (id 1), вырезанный из общего пула потому, что QRATOR банит всё, кроме этого чистого residential-адреса (обоснование в миграции `173_scrape_proxies_add_domclick_affinity.sql`). Авито/Циан/Яндекс могли бы его забрать и оставить Домклик без прокси. - Тип сетевой ошибки классифицируется и попадает в лог. Полноценное разделение «транзиент против бана» не делалось — требует новой колонки, обосновано в PR. ## Проверки, которые стоит отметить Семантика блокировок проверена **эмпирически**, а не рассуждением: ревьюер держал открытую транзакцию с `FOR UPDATE` на одной строке и параллельной сессией пробовал `FOR UPDATE NOWAIT` на той, что видна только внутри коррелированного подзапроса — прошло мгновенно, тогда как на первой строке честно упало с ошибкой блокировки. Вывод: алиас подзапроса под внешний `FOR UPDATE` не попадает, патологии нет. Фальсификация проведена подменой SQL напрямую в файле (а не через git — иначе получается бесполезная ошибка импорта константы): убрал условие защиты — тест упал; убрал `enabled = true` — упал тест реанимации. Отдельно: автор сам нашёл и починил второй экземпляр методологической проблемы, на который я указал для первого — его собственный новый тест проходил бы и против незащищённого SQL, потому что мок реализовывал защиту в Python независимо от запроса. Тест проверял сам себя. ## Мелкая находка, не блокер `proxy_pool.py:174-180` — «бэкап» в EXISTS-подзапросе засчитывается только по `other.enabled`, без учёта `consecutive_fails`. Сейчас не проявляется (у `domclick` ровно один узел, EXISTS честно даёт ноль). Но если у выделенной affinity появится второй узел и он окажется в карантине (3-4 фейла, ещё `enabled`, но `acquire` его уже не выдаёт), подзапрос сочтёт его валидным бэкапом и отдаст последний реально рабочий узел чужому провайдеру. Лечится одним условием `AND other.consecutive_fails < CAST(:max_fails AS integer)` — стоит закрыть при следующем касании файла. ## Статус пунктов 1. ✅ Авто-восстановление — сделано. 2. ⬜ Довести сигнал бана до пула — не сделано, самый рискованный пункт (правка в горячем пути скрапера). 3. ✅ Фолбэк не берёт выключенные + защита последнего узла — сделано. 4. ⬜ Здоровье по паре «прокси + источник» — исходный запрос владельца, имеет смысл после пункта 2. 5. 🔄 Ротация по бану — PR #2611 на глубоком ревью. Механизм и ручной запуск; автоматический триггер невозможен до пункта 2. Плюс отдельно заведена #2610 — пул не отличает ручное выключение от авто-отключения.
Collaborator

Пункт 1 закрыт — PR #2653 (merged + прод-verified). Пункт 2 (per-source здоровье, banned_until на пару прокси×источник) остаётся открытым, это отдельная задача.

Что сделано

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

Инструментированы все 4 источника. Тонкость: Avito размечен на raise-сайтах, а не в __aexit__pipeline.py присваивает scraper._browser напрямую и в контекст-менеджер не заходит, централизованный хук пропустил бы весь этот путь. Cian/Yandex проверяют счётчики в __aexit__ до релиза lease. Curl-путь получил маркер-класс ProxyBanError (дремлющий: сегодня все 4 источника на browser-пути).

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

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

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

Прод после деплоя

Маркеры в живом образе (mark_banned, advisory-lock, transport_error, floor на обоих источниках) — на месте. Пул цел: 4 узла (3 any + 1 domclick), все enabled, disabled_reason пуст, ложных банов нет.

Замечу по состоянию из тела issue: «5 из 8 выключены, живых три» — устарело, мёртвые mobileproxy-узлы удалены (#2614), самовосстановление (#2609) подняло остальные.

**Пункт 1 закрыт** — PR #2653 (merged + прод-verified). Пункт 2 (per-source здоровье, `banned_until` на пару прокси×источник) остаётся открытым, это отдельная задача. ## Что сделано Сигнал бана теперь доходит до пула: `BrowserFetcher.report_ban()` помечает текущий sticky-lease (#2640) → `proxy_pool.mark_banned()` выключает узел с `disabled_reason='banned:<source>'`. Колонка из #2610 гарантирует, что ipify-проба такой узел **не воскресит** (иначе был бы флаппинг, ровно как предсказано в #2610). Инструментированы все 4 источника. Тонкость: Avito размечен **на raise-сайтах**, а не в `__aexit__` — `pipeline.py` присваивает `scraper._browser` напрямую и в контекст-менеджер не заходит, централизованный хук пропустил бы весь этот путь. Cian/Yandex проверяют счётчики в `__aexit__` **до** релиза lease. Curl-путь получил маркер-класс `ProxyBanError` (дремлющий: сегодня все 4 источника на browser-пути). ## Две защиты, которые пришлось добавить (deep-review, 🟠 HIGH ×2) 1. **Ложный бан.** У Яндекса `_http_get` глотал исключения `fetch()` и возвращал `status_code=0` — этот случай считался в тот же счётчик, что настоящая капча. Живой путь `POST /admin/scrape {"sources":["yandex"]}` делает ровно одну попытку → 1/1 = 100% → здоровый узел выключался бы как забаненный. Добавлен признак `transport_error` (счётчик трогают только контентные провалы — Циан так делал изначально) + порог `attempts >= 3`. Проверено: боевой sweep делает ≥4 попытки на инстанс, детект настоящего бана не страдает. 2. **Выкос пула.** `mark_banned` не выключает последний достижимый для источника узел (EXISTS зеркалит логику `acquire()` **с учётом affinity** — domclick-узел не запасной для avito/cian/yandex). Плюс `pg_advisory_xact_lock`: два параллельных бана разных узлов проходили мимо защиты (одностроч­ный UPDATE не атомарен поперёк строк) и могли обнулить пул **без самолечения**. Три вида отказа остались различимы: бан площадки → `mark_banned`; нет прокси → `NoProxyAvailableError` (#2616); сетевой сбой → обычный `mark_health(ok=False)`. ## Прод после деплоя Маркеры в живом образе (`mark_banned`, advisory-lock, `transport_error`, floor на обоих источниках) — на месте. Пул цел: 4 узла (3 `any` + 1 domclick), все enabled, `disabled_reason` пуст, ложных банов нет. Замечу по состоянию из тела issue: «5 из 8 выключены, живых три» — устарело, мёртвые mobileproxy-узлы удалены (#2614), самовосстановление (#2609) подняло остальные.
Collaborator

Пункт 2 — в работе, PR #2654 (открыт, на ревью, не смержен).

Суть: бан переезжает из глобального enabled=false в таблицу scrape_proxy_source_bans с ключом по паре «узел × источник». acquire(provider) фильтрует по активным банам ЭТОГО источника — узел, забаненный Авито, для Яндекса остаётся первосортным. Это и есть поведение, которое ты описал 31.07.

Сроки: 6 часов на первый бан пары, при повторе удваивается до потолка в 72 часа — узел, который площадка банит раз за разом, перестаёт жечь прогоны, но и не теряется навсегда. Счётчик повторов сбрасывается, если пара неделю чиста после истечения бана.

Защита последнего узла сохранена, но теперь считается ПО ИСТОЧНИКУ: если после бана у acquire(source) не останется ни одного кандидата, бан не записывается — вместо этого WARNING с прямой формулировкой, что нужны новые прокси (#2638). Голодать без прокси хуже, чем ходить через подозрительный.

Миграция заодно чинит мину, которую оставил п.1: узлы с disabled_reason='banned:<источник>' конвертируются в per-source бан и возвращаются в строй. Иначе они зависли бы выключенными навсегда — новый код такой семантики не пишет и ничего её не снимает, а health-проба не воскрешает узлы с непустым disabled_reason (#2610).

Пункт 2 — в работе, PR #2654 (открыт, на ревью, не смержен). Суть: бан переезжает из глобального `enabled=false` в таблицу `scrape_proxy_source_bans` с ключом по паре «узел × источник». `acquire(provider)` фильтрует по активным банам ЭТОГО источника — узел, забаненный Авито, для Яндекса остаётся первосортным. Это и есть поведение, которое ты описал 31.07. Сроки: 6 часов на первый бан пары, при повторе удваивается до потолка в 72 часа — узел, который площадка банит раз за разом, перестаёт жечь прогоны, но и не теряется навсегда. Счётчик повторов сбрасывается, если пара неделю чиста после истечения бана. Защита последнего узла сохранена, но теперь считается ПО ИСТОЧНИКУ: если после бана у `acquire(source)` не останется ни одного кандидата, бан не записывается — вместо этого WARNING с прямой формулировкой, что нужны новые прокси (#2638). Голодать без прокси хуже, чем ходить через подозрительный. Миграция заодно чинит мину, которую оставил п.1: узлы с `disabled_reason='banned:<источник>'` конвертируются в per-source бан и возвращаются в строй. Иначе они зависли бы выключенными навсегда — новый код такой семантики не пишет и ничего её не снимает, а health-проба не воскрешает узлы с непустым `disabled_reason` (#2610).
Collaborator

Закрыто целиком. Все четыре пункта сделаны и проверены на проде.

пункт чем закрыт
1. довести сигнал бана до пула PR #2653
2. здоровье по паре «прокси + источник» PR #2654
3. фолбэк не должен брать выключенные узлы #2616 (отказ вместо мёртвого env-узла) + кросс-affinity фолбэк в acquire
4. авто-восстановление #2609 (health-проба опрашивает и выключенные)

Пункт 2 на проде

Таблица scrape_proxy_source_bans создана, PK по паре (proxy_id, source), каскад на удаление узла. acquire(provider) фильтрует по активным банам этого источника в обоих запросах — и в основном, и в фолбэке на чужую affinity. Пул после деплоя цел: 4 узла, все включены, активных банов ноль, ни один узел не завис с остаточным disabled_reason='banned:…' от пункта 1 (миграция 210 такие конвертирует — проверил, срабатывать было не на чем, но мина обезврежена).

Что нашло глубокое ревью, помимо заявленного

Две вещи, которые тестами не ловились:

«Включён» перестало означать «пригоден». Внутренний EXISTS, защищающий выделенную affinity от увода последнего узла фолбэком, считал backup'ом любой включённый узел этой affinity. После перехода на per-source бан узел может быть включён и при этом забанен своим же источником — и всё равно считался запасом. Сценарий: два domclick-узла, один забанен domclick'ом (законно), фолбэк avito уводит второй, acquire('domclick') возвращает пусто, источник встал при двух формально живых прокси. Сегодня не стреляет — у domclick ровно один выделенный узел, — но выстрелило бы в день появления второго.

У оператора не осталось рычага снять бан. В пункте 1 ложное срабатывание лечилось ручным включением узла; после пункта 2 бан живёт в отдельной таблице, и снять его было нечем, кроме SQL руками. Теперь clear_source_bans вызывается при ручном включении узла (семантика «включил руками = чистый лист») и после успешной ротации exit-IP — сменился адрес, значит бан площадки на старом адресе протух, а строка привязана к узлу и продолжала бы действовать.

Плюс: миграция сужена до известных источников, чтобы не отменить ручное выключение оператора, если он напишет в причину текст с префиксом banned: (формат подсказан комментарием миграции 209); тест защиты перестал дублировать боевую логику и теперь действительно краснеет при удалении предиката; поправлены два описания, оставшиеся от старой модели.

Все правки подтверждены мутацией: при удалении фикса из боевого SQL нужные тесты падают.

Остаётся вне этой задачи

Ротация exit-IP построена (#2611), но не может быть вызвана — ASOCKS_API_TOKEN не задан, таблица ротаций пуста за всю историю. И пул тонкий: три узла any на три площадки при эксклюзивной аренде (#2638). Оба ждут тебя.

**Закрыто целиком.** Все четыре пункта сделаны и проверены на проде. | пункт | чем закрыт | |---|---| | 1. довести сигнал бана до пула | PR #2653 | | 2. здоровье по паре «прокси + источник» | PR #2654 | | 3. фолбэк не должен брать выключенные узлы | #2616 (отказ вместо мёртвого env-узла) + кросс-affinity фолбэк в `acquire` | | 4. авто-восстановление | #2609 (health-проба опрашивает и выключенные) | ## Пункт 2 на проде Таблица `scrape_proxy_source_bans` создана, PK по паре `(proxy_id, source)`, каскад на удаление узла. `acquire(provider)` фильтрует по активным банам этого источника в обоих запросах — и в основном, и в фолбэке на чужую affinity. Пул после деплоя цел: 4 узла, все включены, активных банов ноль, ни один узел не завис с остаточным `disabled_reason='banned:…'` от пункта 1 (миграция 210 такие конвертирует — проверил, срабатывать было не на чем, но мина обезврежена). ## Что нашло глубокое ревью, помимо заявленного Две вещи, которые тестами не ловились: **«Включён» перестало означать «пригоден».** Внутренний EXISTS, защищающий выделенную affinity от увода последнего узла фолбэком, считал backup'ом любой включённый узел этой affinity. После перехода на per-source бан узел может быть включён и при этом забанен своим же источником — и всё равно считался запасом. Сценарий: два domclick-узла, один забанен domclick'ом (законно), фолбэк avito уводит второй, `acquire('domclick')` возвращает пусто, источник встал при двух формально живых прокси. Сегодня не стреляет — у domclick ровно один выделенный узел, — но выстрелило бы в день появления второго. **У оператора не осталось рычага снять бан.** В пункте 1 ложное срабатывание лечилось ручным включением узла; после пункта 2 бан живёт в отдельной таблице, и снять его было нечем, кроме SQL руками. Теперь `clear_source_bans` вызывается при ручном включении узла (семантика «включил руками = чистый лист») и после успешной ротации exit-IP — сменился адрес, значит бан площадки на старом адресе протух, а строка привязана к узлу и продолжала бы действовать. Плюс: миграция сужена до известных источников, чтобы не отменить ручное выключение оператора, если он напишет в причину текст с префиксом `banned:` (формат подсказан комментарием миграции 209); тест защиты перестал дублировать боевую логику и теперь действительно краснеет при удалении предиката; поправлены два описания, оставшиеся от старой модели. Все правки подтверждены мутацией: при удалении фикса из боевого SQL нужные тесты падают. ## Остаётся вне этой задачи Ротация exit-IP построена (#2611), но не может быть вызвана — `ASOCKS_API_TOKEN` не задан, таблица ротаций пуста за всю историю. И пул тонкий: три узла `any` на три площадки при эксклюзивной аренде (#2638). Оба ждут тебя.
Sign in to join this conversation.
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#2600
No description provided.