tradein/avito: отказ сайдкара и пустой пул считаются блоками площадки и рвут добор по доле — тот же дефект, что #3283, но по ratio #3288

Open
opened 2026-08-30 20:50:04 +00:00 by lekss361 · 10 comments
Owner

Прогон 5425 (30.08, 19:55–20:33 UTC) оборвался по доле блоков на 90 попытках из снапшота в 1600, обогатив 41 карточку. Из 48 засчитанных блоков отказом площадки было 7.

counters: {"blocked": 48, "enriched": 41, "attempted": 90,
           "ban_kinds": {"infra": 41, "platform": 7},
           "abort_reason": "ratio"}
status:   banned
error:    «avito_detail_backfill остановлен блоками источника — blocked=48,
           обогащено 41 из 90 попыток; причина: AvitoSidecarUnavailableError (40 из 49)»

Прогон помечен banned и рапортует «остановлен блоками источника», хотя доминирующая причина, которую он же и печатает, — отказ НАШЕГО тракта.

Цепочка

  1. packages/scraper-kit/src/scraper_kit/providers/avito/detail.py:541-553 — любой отказ сайдкара заворачивается в AvitoSidecarUnavailableError. В комментарии прямо: «Площадка тут ни при чём — страница до неё не доехала, и её ответ мы не видели». Сюда же попадает NoProxyAvailableError пустого пула — то есть случай, когда запрос не отправлялся вовсе.
  2. scraper_kit/avito_exceptions.py:25AvitoSidecarUnavailableError объявлен подтипом AvitoRateLimitedError.
  3. app/tasks/avito_detail_backfill.py:648except (AvitoBlockedError, AvitoRateLimitedError) ловит его наравне с настоящим баном и на строке 651 зовёт breaker.record_block().
  4. app/services/backfill_block_breaker.py:91-93record_block() не принимает вида блока вообще: self._consecutive_blocks += 1; self._window.append(True). В окно доли всё падает одинаково.

Знание о природе отказа у кода есть и даже вычисляется в том же цикле: pipeline.py:214 относит AvitoSidecarUnavailableError и NoProxyAvailableError к BAN_KIND_INFRA, строка 654 бэкфилла складывает это в block_ban_kinds. Но ban_kinds уходит только в телеметрию (:808) и на решение об остановке не влияет.

Что пустой пул действительно доезжает до ветки блоков — видно в логе прода этого же прогона:

20:05:11 WARNING app.tasks.avito_detail_backfill: run_id=5425 BLOCKED #15/1600 (consecutive=2):
  Avito detail browser fetch failed for https://www.avito.ru/ekaterinburg/kvartiry/... :
  no proxy available for provider='avito' (pool empty in prod, refusing env/direct fallback — #2616)

Почему это не то же самое, что уже закрытый #3283

#3283 — тот же класс ошибки в domclick_detail_backfill, и он закрыт (PR #3286, #3287): там NoProxyAvailableError перестал быть блоком вовсе, а транспортные сбои ушли в consecutive_soft со своим порогом. В авито критерий обрыва другой — доля в скользящем окне (#3184), — и правка #3283 его не касается. Цена ошибки здесь выше: доля рвёт прогон при живой площадке и живых 41 успехах.

Предлагаемая правка

  1. record_block() принимает вид (platform / infra), и в окно доли идёт только platform. Инфраструктурный отказ — в знаменатель, ровно как уже делает record_failure() (backfill_block_breaker.py:102).
  2. NoProxyAvailableError не блок и не отказ площадки: прогон завершается отдельной причиной «нечем ходить», как в домклике после #3286 (counters.no_proxy_stop, error = «пул прокси пуст — к площадке не ходили»).
  3. Не помечать прогон banned, когда ban_kinds доминирует infra — сейчас _dominant_ban_kind считает вид, но статус всё равно «забанен площадкой».

Приёмка: прогон, у которого все отказы — infra, доходит до конца снапшота или завершается по бюджету, но НЕ обрывается по доле и НЕ получает статус banned.

Смежное

  • #3283 (закрыт) — тот же дефект в домклике, оттуда же готовая форма решения.
  • #2638 — пул исчерпывается и порождает эти самые infra-«блоки»; корень в дефиците узлов.
  • #3184 — введение критерия по доле, здесь уточняется, что в долю попадает.
Прогон 5425 (30.08, 19:55–20:33 UTC) оборвался по доле блоков на 90 попытках из снапшота в 1600, обогатив 41 карточку. Из 48 засчитанных блоков **отказом площадки было 7**. ``` counters: {"blocked": 48, "enriched": 41, "attempted": 90, "ban_kinds": {"infra": 41, "platform": 7}, "abort_reason": "ratio"} status: banned error: «avito_detail_backfill остановлен блоками источника — blocked=48, обогащено 41 из 90 попыток; причина: AvitoSidecarUnavailableError (40 из 49)» ``` Прогон помечен `banned` и рапортует «остановлен блоками источника», хотя доминирующая причина, которую он же и печатает, — отказ НАШЕГО тракта. ## Цепочка 1. `packages/scraper-kit/src/scraper_kit/providers/avito/detail.py:541-553` — любой отказ сайдкара заворачивается в `AvitoSidecarUnavailableError`. В комментарии прямо: *«Площадка тут ни при чём — страница до неё не доехала, и её ответ мы не видели»*. Сюда же попадает `NoProxyAvailableError` пустого пула — то есть случай, когда запрос **не отправлялся вовсе**. 2. `scraper_kit/avito_exceptions.py:25` — `AvitoSidecarUnavailableError` объявлен подтипом `AvitoRateLimitedError`. 3. `app/tasks/avito_detail_backfill.py:648` — `except (AvitoBlockedError, AvitoRateLimitedError)` ловит его наравне с настоящим баном и на строке 651 зовёт `breaker.record_block()`. 4. `app/services/backfill_block_breaker.py:91-93` — `record_block()` не принимает вида блока вообще: `self._consecutive_blocks += 1; self._window.append(True)`. В окно доли всё падает одинаково. Знание о природе отказа у кода есть и даже вычисляется в том же цикле: `pipeline.py:214` относит `AvitoSidecarUnavailableError` и `NoProxyAvailableError` к `BAN_KIND_INFRA`, строка 654 бэкфилла складывает это в `block_ban_kinds`. Но `ban_kinds` уходит только в телеметрию (`:808`) и на решение об остановке не влияет. Что пустой пул действительно доезжает до ветки блоков — видно в логе прода этого же прогона: ``` 20:05:11 WARNING app.tasks.avito_detail_backfill: run_id=5425 BLOCKED #15/1600 (consecutive=2): Avito detail browser fetch failed for https://www.avito.ru/ekaterinburg/kvartiry/... : no proxy available for provider='avito' (pool empty in prod, refusing env/direct fallback — #2616) ``` ## Почему это не то же самое, что уже закрытый #3283 #3283 — тот же класс ошибки в `domclick_detail_backfill`, и он закрыт (PR #3286, #3287): там `NoProxyAvailableError` перестал быть блоком вовсе, а транспортные сбои ушли в `consecutive_soft` со своим порогом. В авито критерий обрыва другой — доля в скользящем окне (#3184), — и правка #3283 его не касается. Цена ошибки здесь выше: доля рвёт прогон при живой площадке и живых 41 успехах. ## Предлагаемая правка 1. `record_block()` принимает вид (`platform` / `infra`), и в окно доли идёт **только** `platform`. Инфраструктурный отказ — в знаменатель, ровно как уже делает `record_failure()` (`backfill_block_breaker.py:102`). 2. `NoProxyAvailableError` не блок и не отказ площадки: прогон завершается отдельной причиной «нечем ходить», как в домклике после #3286 (`counters.no_proxy_stop`, `error = «пул прокси пуст — к площадке не ходили»`). 3. Не помечать прогон `banned`, когда `ban_kinds` доминирует `infra` — сейчас `_dominant_ban_kind` считает вид, но статус всё равно «забанен площадкой». Приёмка: прогон, у которого все отказы — `infra`, доходит до конца снапшота или завершается по бюджету, но НЕ обрывается по доле и НЕ получает статус `banned`. ## Смежное - #3283 (закрыт) — тот же дефект в домклике, оттуда же готовая форма решения. - #2638 — пул исчерпывается и порождает эти самые infra-«блоки»; корень в дефиците узлов. - #3184 — введение критерия по доле, здесь уточняется, что в долю попадает.
Author
Owner

Конкретика для приёмки, чтобы правка не поехала мимо #3184:

  • 20 подряд отказов сайдкара при снапшоте больше окна не дают abort_reason() == "ratio";
  • 14 настоящих AvitoBlockedError из 20 — дают (поведение #3184 не меняется);
  • цепочка NoProxyAvailableErrorAvitoSidecarUnavailableError распознаётся по __cause__/__context__ (приём _iter_causes из domclick_detail_backfill.py), а не по тексту сообщения.

Последний пункт важен отдельно: соблазн распознавать пустой пул по подстроке «no proxy available» велик, потому что она попадает в текст обёртки, но это ровно тот способ, которым #3272 уже один раз обжёгся — маркер блока совпал внутри пользовательского текста.

Отдельная оговорка, чтобы при реализации не приняли лишнего: колонка диагноза не врёт. _dominant_ban_kind (scrape_runs.py:823-846) при 41 из 48 честно проставляет ban_kind='infra'. Чинить надо решение об обрыве, а не бухгалтерию диагнозов.

Конкретика для приёмки, чтобы правка не поехала мимо #3184: - 20 подряд отказов сайдкара при снапшоте больше окна **не** дают `abort_reason() == "ratio"`; - 14 настоящих `AvitoBlockedError` из 20 — **дают** (поведение #3184 не меняется); - цепочка `NoProxyAvailableError` → `AvitoSidecarUnavailableError` распознаётся по `__cause__`/`__context__` (приём `_iter_causes` из `domclick_detail_backfill.py`), а **не** по тексту сообщения. Последний пункт важен отдельно: соблазн распознавать пустой пул по подстроке «no proxy available» велик, потому что она попадает в текст обёртки, но это ровно тот способ, которым #3272 уже один раз обжёгся — маркер блока совпал внутри пользовательского текста. Отдельная оговорка, чтобы при реализации не приняли лишнего: колонка диагноза не врёт. `_dominant_ban_kind` (`scrape_runs.py:823-846`) при 41 из 48 честно проставляет `ban_kind='infra'`. Чинить надо **решение об обрыве**, а не бухгалтерию диагнозов.
Author
Owner

Поправка к тексту тикета: причину я назвал неверно, и знак ошибки противоположный.

В теле выше написано «из 48 засчитанных блоков отказом площадки было 7». Замер 31.08 показывает, что это не так: infra у Авито — это в основном как раз отказы площадки, а не сбои нашего тракта. Дефект реален, но состоит в обратном.

Замер

Прямая проба карточек через узлы пула с разбором цепочки __cause__ (пул не портили — подменный провайдер, health/ban не пишутся):

узел 1  → 5 из 5: SidecarBanPageError, ban_page, upstream=403
узел 9  → успех; AvitoBlockedError (firewall/soft-block)

Ни одного таймаута, ни одного транспортного сбоя, ни одной 500-ки без маркера бана. Всё, что падало, — отказ площадки: либо статическая бан-страница «Доступ ограничен: проблема с IP» (_BAN_MARKERS в browser/server.py:1620, маркер снят с Авито живьём), либо soft-block, опознанный парсером.

Почему бан площадки приезжает как infra

Сайдкар на бан-страницу поднимает BanPageDetectedError и отвечает HTTP 500 с ban_page: true в теле (browser/server.py:1356-1365), причём код ответа намеренно не меняется. Клиент разбирает тело и поднимает SidecarBanPageError (browser_fetcher.py:206) — тип, существующий ровно для того, чтобы отличить подтверждённый бан от обычной 500-ки.

Домклик этот тип ловит отдельно — providers/domclick/detail.py:551. У Авито такой ветки нет: providers/avito/detail.py:541 ловит всё общим except Exception и заворачивает в AvitoSidecarUnavailableError с комментарием «Площадка тут ни при чём». Для бан-страницы этот комментарий неверен — площадка тут именно при чём. Дальше pipeline.py:214 честно относит AvitoSidecarUnavailableError к BAN_KIND_INFRA, и подтверждённый бан IP получает диагноз «наша инфраструктура».

Ровно та же ошибка, что я допустил в PR #3286 для Домклика и исправил в #3287: SidecarBanPageError наследует httpx.HTTPStatusError, поэтому широкая ловля забирает его себе.

Следствие, которое дороже неверной телеметрии

report_ban на avito-пути не вызывается вообще — он есть только в providers/avito/serp.py:478, в detail его нет. Значит:

  1. Строка в scrape_proxy_source_bans не пишется. За 7 суток там 2 бана domclick и 1 cian — и ноль avito, при 58 прогонах avito_detail_backfill из 63 со статусом banned.
  2. Забаненный узел продолжает выдаваться этому же источнику — следующая карточка гарантированно ловит ту же бан-страницу.
  3. Вместо адресного бана узел выбывает через mark_health(ok=False)consecutive_fails >= 3глобальный карантин. Бан Авито выносит узел и для Домклика, и для Циана. Именно это я наблюдал 30.08: узел 12 ушёл в карантин сразу после avito-прогона и перестал быть доступен добору Домклика.

Контраст в цифрах: avito_city_sweep (SERP, где report_ban есть) — 0 прогонов banned из 7, увидено 950 объявлений. avito_detail_backfill (где его нет) — 58 из 63.

Что чинить (замена пункта 2 в теле тикета)

  1. providers/avito/detail.py — ловить SidecarBanPageError отдельной веткой ДО общего except Exception, поднимать AvitoBlockedError (площадка), не AvitoSidecarUnavailableError. Порядок веток закрепить тестом — как test_sidecar_ban_page_is_a_subclass_of_httpx_error в #3287.
  2. Звать report_ban на подтверждённой бан-странице, как это уже делает SERP-путь. Тогда узел снимается адресно для avito и остаётся в строю для остальных площадок, вместо нынешнего глобального карантина.
  3. Пункт про record_block() и вид блока остаётся в силе, но перестаёт быть главным: после правки 1 эти блоки станут честным platform, и обрыв по доле будет означать то, что должен.

Пункт про NoProxyAvailableError (пункт 1 в теле) в силе — он тоже проходит через ту же обёртку.

Приёмка меняется: тест должен проверять, что бан-страница Авито доезжает как platform, а не infra, и что за ней следует report_ban.

**Поправка к тексту тикета: причину я назвал неверно, и знак ошибки противоположный.** В теле выше написано «из 48 засчитанных блоков отказом площадки было 7». Замер 31.08 показывает, что это не так: **`infra` у Авито — это в основном как раз отказы площадки**, а не сбои нашего тракта. Дефект реален, но состоит в обратном. ## Замер Прямая проба карточек через узлы пула с разбором цепочки `__cause__` (пул не портили — подменный провайдер, health/ban не пишутся): ``` узел 1 → 5 из 5: SidecarBanPageError, ban_page, upstream=403 узел 9 → успех; AvitoBlockedError (firewall/soft-block) ``` Ни одного таймаута, ни одного транспортного сбоя, ни одной 500-ки без маркера бана. Всё, что падало, — отказ площадки: либо статическая бан-страница «Доступ ограничен: проблема с IP» (`_BAN_MARKERS` в `browser/server.py:1620`, маркер снят с Авито живьём), либо soft-block, опознанный парсером. ## Почему бан площадки приезжает как `infra` Сайдкар на бан-страницу поднимает `BanPageDetectedError` и отвечает **HTTP 500** с `ban_page: true` в теле (`browser/server.py:1356-1365`), причём код ответа намеренно не меняется. Клиент разбирает тело и поднимает `SidecarBanPageError` (`browser_fetcher.py:206`) — тип, существующий ровно для того, чтобы отличить подтверждённый бан от обычной 500-ки. Домклик этот тип ловит отдельно — `providers/domclick/detail.py:551`. **У Авито такой ветки нет**: `providers/avito/detail.py:541` ловит всё общим `except Exception` и заворачивает в `AvitoSidecarUnavailableError` с комментарием «Площадка тут ни при чём». Для бан-страницы этот комментарий неверен — площадка тут именно при чём. Дальше `pipeline.py:214` честно относит `AvitoSidecarUnavailableError` к `BAN_KIND_INFRA`, и подтверждённый бан IP получает диагноз «наша инфраструктура». Ровно та же ошибка, что я допустил в PR #3286 для Домклика и исправил в #3287: **`SidecarBanPageError` наследует `httpx.HTTPStatusError`, поэтому широкая ловля забирает его себе.** ## Следствие, которое дороже неверной телеметрии `report_ban` на avito-пути **не вызывается вообще** — он есть только в `providers/avito/serp.py:478`, в detail его нет. Значит: 1. Строка в `scrape_proxy_source_bans` не пишется. За 7 суток там 2 бана domclick и 1 cian — и **ноль avito**, при 58 прогонах `avito_detail_backfill` из 63 со статусом `banned`. 2. Забаненный узел продолжает выдаваться этому же источнику — следующая карточка гарантированно ловит ту же бан-страницу. 3. Вместо адресного бана узел выбывает через `mark_health(ok=False)` → `consecutive_fails >= 3` → **глобальный карантин**. Бан Авито выносит узел и для Домклика, и для Циана. Именно это я наблюдал 30.08: узел 12 ушёл в карантин сразу после avito-прогона и перестал быть доступен добору Домклика. Контраст в цифрах: `avito_city_sweep` (SERP, где `report_ban` есть) — **0 прогонов `banned` из 7**, увидено 950 объявлений. `avito_detail_backfill` (где его нет) — **58 из 63**. ## Что чинить (замена пункта 2 в теле тикета) 1. `providers/avito/detail.py` — ловить `SidecarBanPageError` отдельной веткой ДО общего `except Exception`, поднимать `AvitoBlockedError` (площадка), не `AvitoSidecarUnavailableError`. Порядок веток закрепить тестом — как `test_sidecar_ban_page_is_a_subclass_of_httpx_error` в #3287. 2. Звать `report_ban` на подтверждённой бан-странице, как это уже делает SERP-путь. Тогда узел снимается адресно для avito и остаётся в строю для остальных площадок, вместо нынешнего глобального карантина. 3. Пункт про `record_block()` и вид блока остаётся в силе, но перестаёт быть главным: после правки 1 эти блоки станут честным `platform`, и обрыв по доле будет означать то, что должен. Пункт про `NoProxyAvailableError` (пункт 1 в теле) в силе — он тоже проходит через ту же обёртку. **Приёмка меняется**: тест должен проверять, что бан-страница Авито доезжает как `platform`, а не `infra`, и что за ней следует `report_ban`.
Author
Owner

Замер 31.08 — у этого дефекта есть следствие крупнее, чем неверная метка в counters: отказы Авито выносят в карантин весь прокси-пул, и остальные три площадки простаивают заодно.

Что наблюдалось. В 13:33 UTC resolve_proxy_url отказывал по ВСЕМ четырём источникам:

yandex   ProxyPoolExhaustedError  pool_total=7 banned_for_source=0 unhealthy_or_disabled=7
cian     ProxyPoolExhaustedError  pool_total=7 banned_for_source=0 unhealthy_or_disabled=7
avito    ProxyPoolExhaustedError  pool_total=7 banned_for_source=0 unhealthy_or_disabled=7
domclick ProxyPoolExhaustedError  pool_total=7 banned_for_source=0 unhealthy_or_disabled=7

banned_for_source=0 — забанен по источнику никто. Все три включённых узла (9, 13, 14) стояли на consecutive_fails=3, то есть ровно на потолке MAX_CONSECUTIVE_FAILS.

Живая проба тех же трёх узлов в ту же минуту: все три отдают exit-IP и главную Яндекса на 4 МБ. Узлы исправны — неисправен учёт.

Путь в коде, по которому это происходит (packages/scraper-kit/src/scraper_kit/browser_fetcher.py:865-873):

resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
_raise_for_sidecar_status(resp)   # бросает SidecarBanPageError на странице-бане площадки
...
except Exception:
    self.last_response_status = None
    self._report_fetch_result(False)   # → mark_health(ok=False) → consecutive_fails += 1
    raise

SidecarBanPageError — отказ ПЛОЩАДКИ, но по типу это httpx.HTTPStatusError, и общий except Exception не отличает его от обрыва транспорта. _report_fetch_result(False) зовёт mark_health(ok=False) на каждый такой /fetch (это задокументировано как осознанная тонкая грануляция, browser_fetcher.py:750-753), а mark_health — решение ГЛОБАЛЬНОЕ для пула, не по источнику.

Дальше арифметика. Прогон 5606 (avito_detail_backfill, оборван по ratio):

attempted=59  blocked=33  enriched=24
ban_kinds={"infra": 26, "platform": 7}
block_streak_histogram={"1": 5, "2": 6, "3": 2, "4": 1, "6": 1}

Доля блоков 56%, и в гистограмме прямо видны серии длиной 3, 4 и 6. Три отказа подряд на одном lease — и узел выбывает из acquire И из resolve_proxy_url для yandex, cian, domclick тоже. При такой доле три подряд набираются за минуты, а enabled узлов сейчас всего три.

Почему healthcheck не спасает: прогон 5607 в 13:14 отчитался ok=4, failed=0, revived=1, а к 13:33 узлы снова были на 3. Healthcheck обнуляет счётчик раз в 30 минут, avito-прогон набивает его обратно быстрее.

Оговорка о доказанности: путь в коде и арифметика серий проверены прямо, а вот сам инкремент «вживую» я не заснял — когда я поставил наблюдение за счётчиками, прогон 5606 уже завершился, и за 8 замеров подряд счётчики так и остались на нуле (наблюдать было нечего). То есть механизм установлен по коду и по гистограмме, но не подтверждён видеозаписью инкремента.

Временная мера, применённая на проде: consecutive_fails=0 трём узлам, прошедшим живую пробу. Резолвер снова отдаёт узлы всем источникам. Это band-aid — при следующем avito-прогоне повторится.

Что из этого следует для формулировки задачи. Мало переименовать infraplatform в counters. Нужно, чтобы отказ площадки шёл в mark_banned(source=...) (бан по паре «узел × источник», для чего scrape_proxy_source_bans и заведена), а НЕ в mark_health(ok=False). Иначе площадка с высокой долей банов продолжит через общий счётчик здоровья гасить сбор с площадок, которые вообще не возражают: yandex сегодня даёт 200 из 200 карточек при нуле блоков.

Замер 31.08 — у этого дефекта есть следствие крупнее, чем неверная метка в counters: **отказы Авито выносят в карантин весь прокси-пул, и остальные три площадки простаивают заодно.** Что наблюдалось. В 13:33 UTC `resolve_proxy_url` отказывал по ВСЕМ четырём источникам: ``` yandex ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7 cian ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7 avito ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7 domclick ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7 ``` `banned_for_source=0` — забанен по источнику никто. Все три включённых узла (9, 13, 14) стояли на `consecutive_fails=3`, то есть ровно на потолке `MAX_CONSECUTIVE_FAILS`. Живая проба тех же трёх узлов в ту же минуту: все три отдают exit-IP и главную Яндекса на 4 МБ. Узлы исправны — неисправен учёт. Путь в коде, по которому это происходит (`packages/scraper-kit/src/scraper_kit/browser_fetcher.py:865-873`): ```python resp = await self._client.post(f"{self._endpoint}/fetch", json=payload) _raise_for_sidecar_status(resp) # бросает SidecarBanPageError на странице-бане площадки ... except Exception: self.last_response_status = None self._report_fetch_result(False) # → mark_health(ok=False) → consecutive_fails += 1 raise ``` `SidecarBanPageError` — отказ ПЛОЩАДКИ, но по типу это `httpx.HTTPStatusError`, и общий `except Exception` не отличает его от обрыва транспорта. `_report_fetch_result(False)` зовёт `mark_health(ok=False)` на каждый такой `/fetch` (это задокументировано как осознанная тонкая грануляция, `browser_fetcher.py:750-753`), а `mark_health` — решение ГЛОБАЛЬНОЕ для пула, не по источнику. Дальше арифметика. Прогон 5606 (`avito_detail_backfill`, оборван по `ratio`): ``` attempted=59 blocked=33 enriched=24 ban_kinds={"infra": 26, "platform": 7} block_streak_histogram={"1": 5, "2": 6, "3": 2, "4": 1, "6": 1} ``` Доля блоков 56%, и в гистограмме прямо видны серии длиной 3, 4 и 6. Три отказа подряд на одном lease — и узел выбывает из `acquire` И из `resolve_proxy_url` для yandex, cian, domclick тоже. При такой доле три подряд набираются за минуты, а `enabled` узлов сейчас всего три. Почему healthcheck не спасает: прогон 5607 в 13:14 отчитался `ok=4, failed=0, revived=1`, а к 13:33 узлы снова были на 3. Healthcheck обнуляет счётчик раз в 30 минут, avito-прогон набивает его обратно быстрее. Оговорка о доказанности: путь в коде и арифметика серий проверены прямо, а вот сам инкремент «вживую» я не заснял — когда я поставил наблюдение за счётчиками, прогон 5606 уже завершился, и за 8 замеров подряд счётчики так и остались на нуле (наблюдать было нечего). То есть механизм установлен по коду и по гистограмме, но не подтверждён видеозаписью инкремента. Временная мера, применённая на проде: `consecutive_fails=0` трём узлам, прошедшим живую пробу. Резолвер снова отдаёт узлы всем источникам. Это band-aid — при следующем avito-прогоне повторится. Что из этого следует для формулировки задачи. Мало переименовать `infra` → `platform` в counters. Нужно, чтобы отказ площадки шёл в `mark_banned(source=...)` (бан по паре «узел × источник», для чего `scrape_proxy_source_bans` и заведена), а НЕ в `mark_health(ok=False)`. Иначе площадка с высокой долей банов продолжит через общий счётчик здоровья гасить сбор с площадок, которые вообще не возражают: yandex сегодня даёт 200 из 200 карточек при нуле блоков.
Author
Owner

Оговорка из прошлого комментария снята: инкремент увиден вживую, а не выведен по коду.

Наблюдение за consecutive_fails включённых узлов, синхронно с прогоном Авито:

16:26  fails[1:0  9:3  13:0  14:3]   avito 5618 banned, 16 блоков из 27
16:45  fails[1:0  9:0  13:0  14:0]   ← healthcheck сбросил
17:15  fails[1:0  9:1  13:0  14:0]

Узлы 9 и 14 стоят на потолке MAX_CONSECUTIVE_FAILS в момент прогона с долей блоков 16/27, через 19 минут их обнуляет healthcheck, дальше счётчик начинает расти заново. Это ровно тот цикл, который предполагался: Авито набивает счётчик быстрее, чем healthcheck его чистит.

Состояние на 01.09 10:30 — цикл продолжается, узлы 9, 13 и 14 снова все на 3:

 id | отказов | вкл | привязка
  1 |       0 | t   | yandex
  9 |       3 | t   | any
 13 |       3 | t   | any
 14 |       3 | t   | any

То есть прямо сейчас resolve_proxy_url снова отказывает Циану, Авито и Домклику, а Яндекс работает — но только потому, что у него выделенный узел 1, до которого Авито не дотягивается. Это наглядно показывает цену дефекта: единственный источник, изолированный от общего счётчика здоровья, — единственный, кто не простаивает.

Ручной сброс, применённый вчера, ожидаемо не удержался. Сбор при этом не встал совсем: healthcheck чистит счётчик каждые 30 минут, и площадки успевают отработать в промежутках — например, cian_detail_backfill взял 385/400 в 20:03 и 400/400 в 02:03. То есть дефект не убивает сбор, а режет его скважностью, и потому не заметен по статусам прогонов.

Refs #3299, #2638

Оговорка из прошлого комментария снята: инкремент **увиден вживую**, а не выведен по коду. Наблюдение за `consecutive_fails` включённых узлов, синхронно с прогоном Авито: ``` 16:26 fails[1:0 9:3 13:0 14:3] avito 5618 banned, 16 блоков из 27 16:45 fails[1:0 9:0 13:0 14:0] ← healthcheck сбросил 17:15 fails[1:0 9:1 13:0 14:0] ``` Узлы 9 и 14 стоят на потолке `MAX_CONSECUTIVE_FAILS` в момент прогона с долей блоков 16/27, через 19 минут их обнуляет healthcheck, дальше счётчик начинает расти заново. Это ровно тот цикл, который предполагался: Авито набивает счётчик быстрее, чем healthcheck его чистит. Состояние на 01.09 10:30 — цикл продолжается, узлы 9, 13 и 14 снова все на 3: ``` id | отказов | вкл | привязка 1 | 0 | t | yandex 9 | 3 | t | any 13 | 3 | t | any 14 | 3 | t | any ``` То есть прямо сейчас `resolve_proxy_url` снова отказывает Циану, Авито и Домклику, а Яндекс работает — но только потому, что у него выделенный узел 1, до которого Авито не дотягивается. Это наглядно показывает цену дефекта: единственный источник, изолированный от общего счётчика здоровья, — единственный, кто не простаивает. Ручной сброс, применённый вчера, ожидаемо не удержался. Сбор при этом не встал совсем: healthcheck чистит счётчик каждые 30 минут, и площадки успевают отработать в промежутках — например, `cian_detail_backfill` взял 385/400 в 20:03 и 400/400 в 02:03. То есть дефект не убивает сбор, а режет его скважностью, и потому не заметен по статусам прогонов. Refs #3299, #2638
Collaborator

Свежий случай 01.09 — и он ВЫВОРАЧИВАЕТ пропорцию наизнанку

Прогон 5676 (07:16–07:22 UTC), алерт «доля блоков 15/20 в окне (порог 70%)», итог attempted=21 enriched=5 blocked=15 gone=1.

Разобрал все 15 блоков по фактической причине:

причина шт это отказ площадки?
BanPageDetectedError: бан-страница (проблема с IP) → сайдкар отдаёт HTTP 500 9 да
HTTP 439 (browser-mode) — QRATOR 5 да
no proxy available (pool empty) 1 нет (#3310)

14 из 15 — настоящие отказы Авито. То есть здесь обрыв по доле сработал ПРАВИЛЬНО, в отличие от прогона 5425 из тела issue, где infra было 41 из 48.

Но по дороге я сам попался на формулировку — и это часть дефекта

В логе бэкфилла эти девять выглядят так:

BLOCKED #13/1600 (consecutive=3): Avito detail: sidecar detected platform refusal
for https://...: Server error '500 Internal Server Error' for url 'http://tradein-browser:3000/fetch'

Читается как «наш сайдкар сломался и отдал 500». Я именно так и прочёл, и успел сказать владельцу, что две трети «отказов площадки» — наша поломка. Это было неверно: лог самого сайдкара показывает BanPageDetectedError: бан-страница (проблема с IP) — он корректно распознал бан-страницу и просто транспортировал её кодом 500.

То есть к предложенной правке добавляется четвёртый пункт, дешёвый и отдельный от остальных:

  1. Корректно распознанный бан площадки не должен ехать кодом 500. Сайдкар уже знает вид отказа (BanPageDetectedError против настоящей внутренней ошибки) — пусть отдаёт его отличимо (например 403/451 + поле вида в теле), а вызывающий печатает «Авито показал бан-страницу», а не «Server error 500 for url tradein-browser:3000/fetch». Сейчас единственный код 500 одинаково означает и «площадка забанила», и «сайдкар упал» — а это ровно те два случая, которые весь этот issue и просит различать.

Пока эти два случая неотличимы в логе, разбор каждого инцидента начинается с ложного следа — проверено на себе сегодня.

Контекст

Единственный узел, доступный для avito (id=13), в этот момент был сожжён: Авито показывал ему страницу «проблема с IP». Остальные — привязка к yandex (1), баны (9, 14), выключены по 407 (10, 11) и истёкшая аренда (12). Корень тот же — #2638.

## Свежий случай 01.09 — и он ВЫВОРАЧИВАЕТ пропорцию наизнанку Прогон 5676 (07:16–07:22 UTC), алерт «доля блоков 15/20 в окне (порог 70%)», итог `attempted=21 enriched=5 blocked=15 gone=1`. Разобрал все 15 блоков по фактической причине: | причина | шт | это отказ площадки? | |---|---|---| | `BanPageDetectedError: бан-страница (проблема с IP)` → сайдкар отдаёт **HTTP 500** | 9 | **да** | | `HTTP 439 (browser-mode)` — QRATOR | 5 | **да** | | `no proxy available (pool empty)` | 1 | нет (#3310) | **14 из 15 — настоящие отказы Авито.** То есть здесь обрыв по доле сработал ПРАВИЛЬНО, в отличие от прогона 5425 из тела issue, где `infra` было 41 из 48. ## Но по дороге я сам попался на формулировку — и это часть дефекта В логе бэкфилла эти девять выглядят так: ``` BLOCKED #13/1600 (consecutive=3): Avito detail: sidecar detected platform refusal for https://...: Server error '500 Internal Server Error' for url 'http://tradein-browser:3000/fetch' ``` Читается как «наш сайдкар сломался и отдал 500». Я именно так и прочёл, и успел сказать владельцу, что две трети «отказов площадки» — наша поломка. Это было неверно: лог **самого сайдкара** показывает `BanPageDetectedError: бан-страница (проблема с IP)` — он корректно распознал бан-страницу и просто транспортировал её кодом 500. То есть к предложенной правке добавляется четвёртый пункт, дешёвый и отдельный от остальных: 4. **Корректно распознанный бан площадки не должен ехать кодом 500.** Сайдкар уже знает вид отказа (`BanPageDetectedError` против настоящей внутренней ошибки) — пусть отдаёт его отличимо (например 403/451 + поле вида в теле), а вызывающий печатает «Авито показал бан-страницу», а не «Server error 500 for url tradein-browser:3000/fetch». Сейчас единственный код 500 одинаково означает и «площадка забанила», и «сайдкар упал» — а это ровно те два случая, которые весь этот issue и просит различать. Пока эти два случая неотличимы в логе, разбор каждого инцидента начинается с ложного следа — проверено на себе сегодня. ## Контекст Единственный узел, доступный для avito (id=13), в этот момент был сожжён: Авито показывал ему страницу «проблема с IP». Остальные — привязка к yandex (1), баны (9, 14), выключены по `407` (10, 11) и истёкшая аренда (12). Корень тот же — #2638.
Collaborator

Часть A закрыта PR #3357 (на проде с 05.09 ~18:50 UTC, BUILD_SHA=63dbc20): бан площадки из сайдкара → report_ban по паре узел×источник, mark_health(ok=False) не зовётся (глобального карантина нет); дедуп по lease; провайдерский report_ban на ветке SidecarBanPageError убран (после ротации банил невиновный узел); бан-страница = один POST, не два; NoProxyAvailableError в ротации не маскирует диагноз platform.

Приёмка событийная (годно до 12.09): после первого avito-прогона с бан-страницами —

  • SELECT * FROM scrape_proxy_source_bans WHERE source='avito' ORDER BY banned_at DESC LIMIT 5 — записи есть;
  • у тех же узлов consecutive_fails в scrape_proxies не растёт синхронно с прогоном;
  • cian/domclick в это время получают узлы (в логах scheduler нет ProxyPoolExhaustedError … banned_for_source=0 unhealthy_or_disabled=N).

Остались: часть Brecord_block(kind) (в окно доли только platform) + no_proxy_stop в avito_detail_backfill.py по образцу domclick #3286; часть C — сайдкар отдаёт бан-страницу кодом 500 (различимый код/поле вида).

Часть A закрыта PR #3357 (на проде с 05.09 ~18:50 UTC, `BUILD_SHA=63dbc20`): бан площадки из сайдкара → `report_ban` по паре узел×источник, `mark_health(ok=False)` не зовётся (глобального карантина нет); дедуп по lease; провайдерский `report_ban` на ветке `SidecarBanPageError` убран (после ротации банил невиновный узел); бан-страница = один POST, не два; `NoProxyAvailableError` в ротации не маскирует диагноз `platform`. **Приёмка событийная (годно до 12.09):** после первого avito-прогона с бан-страницами — - `SELECT * FROM scrape_proxy_source_bans WHERE source='avito' ORDER BY banned_at DESC LIMIT 5` — записи есть; - у тех же узлов `consecutive_fails` в `scrape_proxies` **не** растёт синхронно с прогоном; - cian/domclick в это время получают узлы (в логах scheduler нет `ProxyPoolExhaustedError … banned_for_source=0 unhealthy_or_disabled=N`). Остались: **часть B** — `record_block(kind)` (в окно доли только `platform`) + `no_proxy_stop` в `avito_detail_backfill.py` по образцу domclick #3286; **часть C** — сайдкар отдаёт бан-страницу кодом 500 (различимый код/поле вида).
Collaborator

Часть B в работе (PR #3367): пп.1-2 закрываются (record_block(kind) — в окно доли только platform; пустой пул → no_proxy_stop/mark_failed). П.3 («не помечать прогон banned, когда доминирует infra») из PR исключён — deep-ревью и полный CI показали, что он ломает два действующих контракта: #3196 (yandex 5xx = infra → banned + ban_kind='infra') и #2764 (SELECT ban_kind, count(*) по колонке, которую пишет только mark_banned). Понижение статуса стирало бы диагноз из строки прогона; cian при этом финализируется другим узлом (app/services/scheduler.py:174) и разошёлся бы с остальными.

Новый пункт 5 (решение, не код-однострочник): если infra-прогоны не должны быть banned

  • ban_kind обязан писаться и в mark_failed/mark_done (или читатели переходят на counters.ban_kinds);
  • cian выровнять на тот же узел/правило;
  • у infra-прогона нужен стоп-кран кроме budget_sec=3600: сейчас транспортный отказ рвёт прогон на 25 (max_consecutive_failures), а отказ сайдкара не рвёт ничем — 1600 бесполезных попыток при мёртвом сайдкаре (цена времени, не бана).

Часть C (сайдкар отдаёт бан-страницу кодом 500) — по-прежнему открыта.

Часть B в работе (PR #3367): пп.1-2 закрываются (`record_block(kind)` — в окно доли только `platform`; пустой пул → `no_proxy_stop`/`mark_failed`). **П.3 («не помечать прогон `banned`, когда доминирует infra») из PR исключён** — deep-ревью и полный CI показали, что он ломает два действующих контракта: #3196 (yandex 5xx = infra → `banned` + `ban_kind='infra'`) и #2764 (`SELECT ban_kind, count(*)` по колонке, которую пишет только `mark_banned`). Понижение статуса стирало бы диагноз из строки прогона; cian при этом финализируется другим узлом (`app/services/scheduler.py:174`) и разошёлся бы с остальными. **Новый пункт 5 (решение, не код-однострочник):** если infra-прогоны не должны быть `banned` — - `ban_kind` обязан писаться и в `mark_failed`/`mark_done` (или читатели переходят на `counters.ban_kinds`); - cian выровнять на тот же узел/правило; - у infra-прогона нужен стоп-кран кроме `budget_sec=3600`: сейчас транспортный отказ рвёт прогон на 25 (`max_consecutive_failures`), а отказ сайдкара не рвёт ничем — 1600 бесполезных попыток при мёртвом сайдкаре (цена времени, не бана). Часть C (сайдкар отдаёт бан-страницу кодом 500) — по-прежнему открыта.
Collaborator

Часть B смержена (PR #3367, 05.09 ~19:35 UTC): record_block(kind) — в окно доли только platform; NoProxyAvailableError по цепочке причин → no_proxy_stop=1 + mark_failed("пул прокси пуст — к площадке не ходили"); суженный п.3: dominant == infra AND produced > 0 → done (нулевой infra-прогон остаётся banned + ban_kind='infra', контракты #3196/#2764 целы).

Критерий приёмки (событийный, годно до 12.09) — первые прогоны avito_detail_backfill после деплоя:

SELECT id, status, counters->>'abort_reason', counters->'ban_kinds', counters->>'no_proxy_stop',
       counters->>'attempted', counters->>'enriched', counters->>'blocked'
  FROM scrape_runs WHERE source='avito_detail_backfill' ORDER BY id DESC LIMIT 5;

ожидание: прогон с доминирующим infra и enriched > 0status='done', abort_reasonratio; пустой пул — status='failed', no_proxy_stop=1, attempted == enriched+blocked+gone+failed; настоящие баны (platform-доминирование) — по-прежнему banned и ratio (#3184 не изменён).

Открыто: п.5 (статус/ban_kind для infra, стоп-кран, cian), часть C (сайдкар отдаёт бан кодом 500).

Часть B смержена (PR #3367, 05.09 ~19:35 UTC): `record_block(kind)` — в окно доли только `platform`; `NoProxyAvailableError` по цепочке причин → `no_proxy_stop=1` + `mark_failed("пул прокси пуст — к площадке не ходили")`; суженный п.3: `dominant == infra AND produced > 0 → done` (нулевой infra-прогон остаётся `banned` + `ban_kind='infra'`, контракты #3196/#2764 целы). **Критерий приёмки (событийный, годно до 12.09)** — первые прогоны `avito_detail_backfill` после деплоя: ```sql SELECT id, status, counters->>'abort_reason', counters->'ban_kinds', counters->>'no_proxy_stop', counters->>'attempted', counters->>'enriched', counters->>'blocked' FROM scrape_runs WHERE source='avito_detail_backfill' ORDER BY id DESC LIMIT 5; ``` ожидание: прогон с доминирующим `infra` и `enriched > 0` — `status='done'`, `abort_reason` ≠ `ratio`; пустой пул — `status='failed'`, `no_proxy_stop=1`, `attempted == enriched+blocked+gone+failed`; настоящие баны (platform-доминирование) — по-прежнему `banned` и `ratio` (#3184 не изменён). Открыто: п.5 (статус/`ban_kind` для infra, стоп-кран, cian), часть C (сайдкар отдаёт бан кодом 500).
Collaborator

Часть C смержена (PR #3379, 06.09 ~22:05 UTC, деплой ea4fb88b): сайдкар на BanPageDetectedError отдаёт 403 (тело прежнее, клиент распознаёт по ban_page, старая 500-сборка тоже распознаётся). Проверено на проде в контейнере tradein-browser (образ пересоздан деплоем): /app/server.py:1485 return web.json_response(error_body, status=403) внутри ветки isinstance(exc, BanPageDetectedError) — маркер, которого в старой сборке не было. Backend/scraper BUILD_SHA=ea4fb88.

Критерий приёмки (событийный, до 13.09): первый зафиксированный бан площадки в логах backend/scraper — строка … (upstream N, ответ сайдкара 403) и ban_kind=platform/ban_kinds.platform в counters прогона, без HTTPStatusError … 500 … tradein-browser. Открытыми остаются п.5 (статус/ban_kind для infra, стоп-кран, cian) и новый #3384 (пул пуст на старте батча).

Часть C смержена (PR #3379, 06.09 ~22:05 UTC, деплой `ea4fb88b`): сайдкар на `BanPageDetectedError` отдаёт **403** (тело прежнее, клиент распознаёт по `ban_page`, старая 500-сборка тоже распознаётся). Проверено на проде в контейнере `tradein-browser` (образ пересоздан деплоем): `/app/server.py:1485 return web.json_response(error_body, status=403)` внутри ветки `isinstance(exc, BanPageDetectedError)` — маркер, которого в старой сборке не было. Backend/scraper `BUILD_SHA=ea4fb88`. **Критерий приёмки (событийный, до 13.09):** первый зафиксированный бан площадки в логах backend/scraper — строка `… (upstream N, ответ сайдкара 403)` и `ban_kind=platform`/`ban_kinds.platform` в counters прогона, без `HTTPStatusError … 500 … tradein-browser`. Открытыми остаются п.5 (статус/`ban_kind` для infra, стоп-кран, cian) и новый #3384 (пул пуст на старте батча).
Collaborator

Приёмка части B (#3367) — первые прогоны после деплоя 05.09 19:35 UTC:

run старт (UTC) status abort ban_kinds attempted / enriched / blocked
6155 05.09 22:24 banned ratio {"platform": 18} 20 / 2 / 18
6168 06.09 01:25 banned ratio {"platform": 16} 20 / 4 / 16
6181 06.09 04:25 banned ratio {"platform": 16} 21 / 5 / 16

Третий сценарий критерия («настоящие баны — platform-доминирование → по-прежнему banned + ratio») наблюдён и выполнен. Сценарии «infra-доминирование ∧ enriched>0 → done» и «пустой пул → failed + no_proxy_stop» пока не наблюдались: infra-отказов 0 (до #3367 в тех же прогонах было infra: 11–14) — то есть транспорт/сайдкар/пул сейчас здоровы, и остался чистый сигнал площадки: Авито блокирует ~80 % detail-запросов (16–18 из 20) на каждом прогоне. Это уже не про классификацию, а про узлы для avito (п. 5 / решение владельца по прокси) — код теперь честно называет это platform.

**Приёмка части B (#3367) — первые прогоны после деплоя 05.09 19:35 UTC:** | run | старт (UTC) | status | abort | ban_kinds | attempted / enriched / blocked | |---|---|---|---|---|---| | 6155 | 05.09 22:24 | banned | ratio | `{"platform": 18}` | 20 / 2 / 18 | | 6168 | 06.09 01:25 | banned | ratio | `{"platform": 16}` | 20 / 4 / 16 | | 6181 | 06.09 04:25 | banned | ratio | `{"platform": 16}` | 21 / 5 / 16 | Третий сценарий критерия («настоящие баны — platform-доминирование → по-прежнему `banned` + `ratio`») наблюдён и выполнен. Сценарии «infra-доминирование ∧ enriched>0 → done» и «пустой пул → failed + no_proxy_stop» пока не наблюдались: **infra-отказов 0** (до #3367 в тех же прогонах было `infra: 11–14`) — то есть транспорт/сайдкар/пул сейчас здоровы, и остался чистый сигнал площадки: Авито блокирует ~80 % detail-запросов (16–18 из 20) на каждом прогоне. Это уже не про классификацию, а про узлы для avito (п. 5 / решение владельца по прокси) — код теперь честно называет это `platform`.
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#3288
No description provided.