Авито detail: HTTP 439 не обработан вовсе, а 429 с бан-страницей QRATOR ретраится на сожжённом IP #3044

Closed
opened 2026-08-21 16:02:52 +00:00 by lekss361 · 8 comments
Owner

Найдено экспериментом по #3035 (2026-08-21). Гипотеза про ?context= не подтвердилась, зато нашлось это — и оно, похоже, и есть главная причина 91-96% отказов avito_detail_backfill.

Наблюдения

1. Эксперимент по контексту. Три группы по 10 карточек через штатный fetch_detail с прогревом, одна сессия, паузы 3-5 с:

Группа Успешно Отказ
A — свежие, с context 1/10 HTTP 439
B — те же, без context 0/10 HTTP 439
C — 2-3 дня, с context 0/10 HTTP 439

Начиная примерно с пятого запроса 439 стал стопроцентным во всех группах, независимо от контекста. То есть доминирует эффект уровня сессии/IP, а не свойство конкретного URL.

2. Что отдаёт Авито сейчас. Тот же URL, отдельная проба:

status: 429
server: QRATOR
content-type: text/html; charset=utf-8
<title>Доступ ограничен: проблема с IP</title>

То есть 429 приходит с полноценной бан-страницей QRATOR по IP, а не как транзиентный throttle.

Два дефекта в классификации ответов

packages/scraper-kit/src/scraper_kit/providers/avito/detail.py:470-471:

sc = response.status_code
is_firewall = sc == 200 and (
    _is_firewall_page(response.text) or _is_detail_soft_block(response.text)
)

Файрвол распознаётся только при sc == 200. Тело ответа не смотрится ни при каком другом коде. Дальше ветвление:

Код Ветка Строка
429 короткий ретрай, затем reconnect 477, 510
403 или is_firewall ротация IP 487
всё остальное, включая 439 raise ValueError(f"avito detail HTTP {sc}")без ретрая и без ротации 537

Дефект A — 439 проваливается насквозь

Нестандартный код, у QRATOR/Авито очевидно означающий блокировку. Не распознан, не классифицирован в ban_kind_of_exception, не даёт ротации IP. Прогон просто сыпется. В логах это выглядит как failed, а не как banned — поэтому по метрикам причина невидима, что и подтверждает вердикт failed-ratio-honest-status: 42 из 46 попыток отказали, причина НЕ установлена.

Дефект B — 429 с бан-страницей ретраится вместо ротации

В коде рядом с _AVITO_429_MAX_RETRIES записано обоснование: «HTTP 429 через backconnect-прокси — НЕ IP-ban, а transient "слишком много одновременных соединений" (лимит 5)». Для backconnect это верно, но QRATOR отдаёт 429 и на бан по IP — тело прямо говорит «Доступ ограничен: проблема с IP».

Значит часть ретраев уходит в стену: мы повторяем запрос на уже забаненном адресе вместо того, чтобы сменить его. Отличить одно от другого можно дёшево — по телу и по server: QRATOR.

Что предлагается

  1. Диагностика первым шагом. Логировать при неизвестном коде: сам код, server-заголовок и первые ~200 символов тела. Сейчас мы про 439 не знаем ничего, кроме числа.
  2. Проверять тело на файрвол-маркеры при любом коде, а не только при 200 — то есть снять условие sc == 200 из вычисления is_firewall.
  3. Развести 429: транзиентный лимит соединений (ретрай на том же IP) против бан-страницы QRATOR (ротация).
  4. Добавить 439 в распознаваемые коды и в ban_kind_of_exception, чтобы он попадал в статистику как banned, а не растворялся в failed с формулировкой «причина не установлена».

Оговорки, честно

  • Дизайн эксперимента был с изъяном, это моя ошибка. Группы шли последовательно A → B → C на одной сессии, поэтому накопленный throttle перекрыл эффект контекста. Правильно было чередовать A/B по одной карточке. Вывод «контекст не важен» из этих данных не следует — следует только то, что доминирует другой фактор.
  • Один успех в группе A против нуля в B формально в пользу контекста, но выборка ничтожна и на этом строить нельзя.
  • Эксперимент и последующая проба, вероятно, поспособствовали текущему бану этого exit-IP. Прокси обновлялись сегодня в 12:30.
  • ?context= в source_url сохраняется (все 1363 строки за двое суток), теряется одной строкой urlparse(source_url).path в avito_detail_backfill.py:433. Правка тривиальна, но по этим данным она не доказанная причина отказов — приоритет ниже, чем у разбора кодов ответа.

Приёмка

  • При неизвестном коде в лог попадают код, server и начало тела
  • is_firewall вычисляется независимо от статус-кода
  • 429 разведён: транзиент против бан-страницы
  • 439 классифицируется и попадает в ban_kind, а не в «причина не установлена»
  • Повторный эксперимент по контексту — с чередованием A/B и на свежей сессии

Refs #3035

Найдено экспериментом по #3035 (2026-08-21). Гипотеза про `?context=` **не подтвердилась**, зато нашлось это — и оно, похоже, и есть главная причина 91-96% отказов `avito_detail_backfill`. ## Наблюдения **1. Эксперимент по контексту.** Три группы по 10 карточек через штатный `fetch_detail` с прогревом, одна сессия, паузы 3-5 с: | Группа | Успешно | Отказ | |---|---|---| | A — свежие, с `context` | 1/10 | `HTTP 439` | | B — те же, без `context` | 0/10 | `HTTP 439` | | C — 2-3 дня, с `context` | 0/10 | `HTTP 439` | Начиная примерно с пятого запроса **439 стал стопроцентным во всех группах, независимо от контекста**. То есть доминирует эффект уровня сессии/IP, а не свойство конкретного URL. **2. Что отдаёт Авито сейчас.** Тот же URL, отдельная проба: ``` status: 429 server: QRATOR content-type: text/html; charset=utf-8 <title>Доступ ограничен: проблема с IP</title> ``` То есть **429 приходит с полноценной бан-страницей QRATOR по IP**, а не как транзиентный throttle. ## Два дефекта в классификации ответов `packages/scraper-kit/src/scraper_kit/providers/avito/detail.py:470-471`: ```python sc = response.status_code is_firewall = sc == 200 and ( _is_firewall_page(response.text) or _is_detail_soft_block(response.text) ) ``` **Файрвол распознаётся только при `sc == 200`.** Тело ответа не смотрится ни при каком другом коде. Дальше ветвление: | Код | Ветка | Строка | |---|---|---| | `429` | короткий ретрай, затем reconnect | 477, 510 | | `403` или `is_firewall` | ротация IP | 487 | | **всё остальное, включая `439`** | `raise ValueError(f"avito detail HTTP {sc}")` — **без ретрая и без ротации** | 537 | ### Дефект A — `439` проваливается насквозь Нестандартный код, у QRATOR/Авито очевидно означающий блокировку. Не распознан, не классифицирован в `ban_kind_of_exception`, не даёт ротации IP. Прогон просто сыпется. В логах это выглядит как `failed`, а не как `banned` — поэтому по метрикам причина невидима, что и подтверждает вердикт `failed-ratio-honest-status: 42 из 46 попыток отказали, причина НЕ установлена`. ### Дефект B — `429` с бан-страницей ретраится вместо ротации В коде рядом с `_AVITO_429_MAX_RETRIES` записано обоснование: «HTTP 429 через backconnect-прокси — НЕ IP-ban, а transient "слишком много одновременных соединений" (лимит 5)». Для backconnect это верно, но QRATOR отдаёт `429` **и на бан по IP** — тело прямо говорит «Доступ ограничен: проблема с IP». Значит часть ретраев уходит в стену: мы повторяем запрос на уже забаненном адресе вместо того, чтобы сменить его. Отличить одно от другого можно дёшево — по телу и по `server: QRATOR`. ## Что предлагается 1. **Диагностика первым шагом.** Логировать при неизвестном коде: сам код, `server`-заголовок и первые ~200 символов тела. Сейчас мы про `439` не знаем ничего, кроме числа. 2. Проверять тело на файрвол-маркеры **при любом коде**, а не только при 200 — то есть снять условие `sc == 200` из вычисления `is_firewall`. 3. Развести `429`: транзиентный лимит соединений (ретрай на том же IP) против бан-страницы QRATOR (ротация). 4. Добавить `439` в распознаваемые коды и в `ban_kind_of_exception`, чтобы он попадал в статистику как `banned`, а не растворялся в `failed` с формулировкой «причина не установлена». ## Оговорки, честно - **Дизайн эксперимента был с изъяном, это моя ошибка.** Группы шли последовательно A → B → C на одной сессии, поэтому накопленный throttle перекрыл эффект контекста. Правильно было чередовать A/B по одной карточке. Вывод «контекст не важен» из этих данных **не следует** — следует только то, что доминирует другой фактор. - Один успех в группе A против нуля в B формально в пользу контекста, но выборка ничтожна и на этом строить нельзя. - Эксперимент и последующая проба, вероятно, поспособствовали текущему бану этого exit-IP. Прокси обновлялись сегодня в 12:30. - `?context=` в `source_url` **сохраняется** (все 1363 строки за двое суток), теряется одной строкой `urlparse(source_url).path` в `avito_detail_backfill.py:433`. Правка тривиальна, но по этим данным она **не доказанная** причина отказов — приоритет ниже, чем у разбора кодов ответа. ## Приёмка - [ ] При неизвестном коде в лог попадают код, `server` и начало тела - [ ] `is_firewall` вычисляется независимо от статус-кода - [ ] `429` разведён: транзиент против бан-страницы - [ ] `439` классифицируется и попадает в `ban_kind`, а не в «причина не установлена» - [ ] Повторный эксперимент по контексту — с чередованием A/B и на свежей сессии Refs #3035
lekss361 added the
bug
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-21 16:03:00 +00:00
Author
Owner

Главное: reconnect-«escape» физически не работает — прокси статический

Владелец подтвердил конфигурацию: статический адрес с самостоятельной ротацией, не backconnect-пул.

Замер, четыре подряд свежесозданные сессии через build_document_session(proxy_url=cfg.scraper_proxy_url):

новая сессия 1: {"ip":"5.227.8.204"}
новая сессия 2: {"ip":"5.227.8.204"}
новая сессия 3: {"ip":"5.227.8.204"}
новая сессия 4: {"ip":"5.227.8.204"}

А в providers/avito/detail.py:70-77 записано обоснование всей retry-стратегии:

403/firewall = залочен лишь текущий exit-IP backconnect-прокси; пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape

Предпосылка неверна. Пересоздание сессии даёт тот же адрес.

Во что это обходится

При заблокированном IP скраппер делает:

  • _AVITO_DETAIL_403_MAX_RETRIES = 5 попыток с backoff 2 с,
  • _AVITO_DETAIL_429_MAX_RETRIES = 8, затем ещё _AVITO_DETAIL_429_RECONNECT_RETRIES через «эфемерную сессию»,

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

Это объясняет 91-96 % отказов avito_detail_backfill лучше, чем любая из прежних гипотез: ни отсутствие ?context=, ни устаревший impersonate не дают такой картины, где отказ становится стопроцентным начиная с пятого запроса и держится.

Что должно происходить вместо этого

При единственном адресе потолок задаётся сдержанностью, а не изворотливостью. Правильное поведение на файрвол-ответ — терминальное: остановить прогон, пометить banned, ждать ротации. Ретраить осмысленно только настоящие сетевые сбои (таймаут соединения, обрыв), но не ответы файрвола.

Уточнение по кодам: 429 и 439 — разные вещи

Код <title> в теле Смысл
429 Доступ ограничен: проблема с IP бан адреса
439 Доступ ограничен: проверка безопасности челлендж

Оба с server: QRATOR. Сейчас первый ретраится на месте, второй не обрабатывается вовсе.

Прогрев врёт об успехе — живое подтверждение

Проба на свежеротированном адресе:

warmup: ok
avito warm-up: search page blocked sc=429
антибот-куки после прогрева: НЕТ
status: 439 | server: QRATOR | «Доступ ограничен: проверка безопасности»

warm_up_session отрапортовал успех, хотя страница поиска отдала 429 и ни одной куки __zzatw-*/cfidsw-* не поставилось. Дальше detail идёт по непрогретой сессии и закономерно получает челлендж.

Это ровно то, что чинит #3038 проверкой кук. Я ранжировал ту правку как «честность отчётности» — на деле это детектор настоящей причины, и её ценность выше, чем я оценил.

Пересмотр приоритетов

  1. Выкинуть reconnect-escape и сделать файрвол-ответ терминальным — иначе всё остальное лечит симптомы. Сюда же: is_firewall считать при любом коде, 439 классифицировать.
  2. #3038 смержить раньше, чем планировалось: он ловит непрогретую сессию до того, как она сожжёт бюджет запросов.
  3. Сокращение объёма (#3039, #3041, #3042) задним числом ценнее, чем казалось: при одном адресе каждый лишний запрос приближает бан.
  4. ?context= (#3035) — в самый конец. Гипотеза не опровергнута, но проверять её имеет смысл только когда сессия перестанет упираться в файрвол.

Refs #3035, #3038

## Главное: reconnect-«escape» физически не работает — прокси статический Владелец подтвердил конфигурацию: **статический адрес с самостоятельной ротацией**, не backconnect-пул. Замер, четыре подряд свежесозданные сессии через `build_document_session(proxy_url=cfg.scraper_proxy_url)`: ``` новая сессия 1: {"ip":"5.227.8.204"} новая сессия 2: {"ip":"5.227.8.204"} новая сессия 3: {"ip":"5.227.8.204"} новая сессия 4: {"ip":"5.227.8.204"} ``` А в `providers/avito/detail.py:70-77` записано обоснование всей retry-стратегии: > 403/firewall = залочен лишь текущий exit-IP backconnect-прокси; **пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape** Предпосылка неверна. Пересоздание сессии даёт **тот же адрес**. ### Во что это обходится При заблокированном IP скраппер делает: - `_AVITO_DETAIL_403_MAX_RETRIES = 5` попыток с backoff 2 с, - `_AVITO_DETAIL_429_MAX_RETRIES = 8`, затем ещё `_AVITO_DETAIL_429_RECONNECT_RETRIES` через «эфемерную сессию», и каждая из этих попыток уходит **с того же самого забаненного адреса**. Мы не уходим от бана — мы его углубляем и продлеваем, тратя до тринадцати лишних запросов на каждую карточку. Это объясняет 91-96 % отказов `avito_detail_backfill` лучше, чем любая из прежних гипотез: ни отсутствие `?context=`, ни устаревший `impersonate` не дают такой картины, где отказ становится стопроцентным начиная с пятого запроса и держится. ### Что должно происходить вместо этого При единственном адресе потолок задаётся сдержанностью, а не изворотливостью. Правильное поведение на файрвол-ответ — **терминальное**: остановить прогон, пометить `banned`, ждать ротации. Ретраить осмысленно только настоящие сетевые сбои (таймаут соединения, обрыв), но не ответы файрвола. ## Уточнение по кодам: 429 и 439 — разные вещи | Код | `<title>` в теле | Смысл | |---|---|---| | `429` | Доступ ограничен: **проблема с IP** | бан адреса | | `439` | Доступ ограничен: **проверка безопасности** | челлендж | Оба с `server: QRATOR`. Сейчас первый ретраится на месте, второй не обрабатывается вовсе. ## Прогрев врёт об успехе — живое подтверждение Проба на свежеротированном адресе: ``` warmup: ok avito warm-up: search page blocked sc=429 антибот-куки после прогрева: НЕТ status: 439 | server: QRATOR | «Доступ ограничен: проверка безопасности» ``` `warm_up_session` **отрапортовал успех**, хотя страница поиска отдала 429 и ни одной куки `__zzatw-*`/`cfidsw-*` не поставилось. Дальше detail идёт по непрогретой сессии и закономерно получает челлендж. Это ровно то, что чинит #3038 проверкой кук. Я ранжировал ту правку как «честность отчётности» — на деле это **детектор настоящей причины**, и её ценность выше, чем я оценил. ## Пересмотр приоритетов 1. **Выкинуть reconnect-escape** и сделать файрвол-ответ терминальным — иначе всё остальное лечит симптомы. Сюда же: `is_firewall` считать при любом коде, `439` классифицировать. 2. **#3038 смержить раньше**, чем планировалось: он ловит непрогретую сессию до того, как она сожжёт бюджет запросов. 3. Сокращение объёма (#3039, #3041, #3042) задним числом ценнее, чем казалось: при одном адресе каждый лишний запрос приближает бан. 4. `?context=` (#3035) — в самый конец. Гипотеза не опровергнута, но проверять её имеет смысл только когда сессия перестанет упираться в файрвол. Refs #3035, #3038
Author
Owner

Замер за 7 дней: avito_detail_backfill не завершился успешно ни разу

Снято на живой БД Poincare 2026-08-26.

status | прогонов | обогащено (total_seen)
-------+----------+-----------------------
banned |    32    |         0
failed |     3    |         0

35 прогонов (раз в 3 часа), ноль успешных. Полный текст последнего:

backfill-honest-status: avito_detail_backfill остановлен блоками источника —
blocked=14, обогащено 6 из 20 попыток;
причина: AvitoBlockedError: Avito detail firewall/soft-block (browser-mode) for <url> (12 из 14) (#2674)

Два наблюдения помимо самого бана

1. Счётчик расходится с фактом. В тексте — «обогащено 6 из 20 попыток», а total_seen у всех 35 прогонов ноль. То есть работа частично делается (6 карточек за прогон), но в метрику не попадает: сверху видно «ничего не собрано», хотя собрано. Любой мониторинг по total_seen тут врёт в обе стороны — и не покажет улучшения, если 439/429 починить.

2. ban_kind поехал в unknown. До 25.08 почти всегда platform, после — половина прогонов с unknown (08-25 21:19, 08-26 00:19, 08-26 03:19). Дискриминатор из #2686/#2764 перестаёт различать причину именно там, где это нужнее всего.

Это НЕ регресс переезда

Проверял специально: прогоны 24.08 на Beget (18:17, 21:17) уже banned/platform с теми же blocked=6..10. Хроника, а не следствие смены хоста.

Побочно закрывает открытый вопрос из #3059

Там опасались, что после переезда браузерный сбор Авито встанет совсем (привязка прокси к старому egress-IP). Судя по этим прогонам — браузерный режим с Poincare работает: browser-mode отрабатывает, 6 из 20 карточек обогащаются, то есть и сайдкар, и прокси живы. Отваливается не транспорт, а сам источник софт-блоком.

## Замер за 7 дней: `avito_detail_backfill` не завершился успешно **ни разу** Снято на живой БД Poincare 2026-08-26. ``` status | прогонов | обогащено (total_seen) -------+----------+----------------------- banned | 32 | 0 failed | 3 | 0 ``` 35 прогонов (раз в 3 часа), ноль успешных. Полный текст последнего: ``` backfill-honest-status: avito_detail_backfill остановлен блоками источника — blocked=14, обогащено 6 из 20 попыток; причина: AvitoBlockedError: Avito detail firewall/soft-block (browser-mode) for <url> (12 из 14) (#2674) ``` ### Два наблюдения помимо самого бана **1. Счётчик расходится с фактом.** В тексте — «обогащено 6 из 20 попыток», а `total_seen` у всех 35 прогонов **ноль**. То есть работа частично делается (6 карточек за прогон), но в метрику не попадает: сверху видно «ничего не собрано», хотя собрано. Любой мониторинг по `total_seen` тут врёт в обе стороны — и не покажет улучшения, если 439/429 починить. **2. `ban_kind` поехал в `unknown`.** До 25.08 почти всегда `platform`, после — половина прогонов с `unknown` (08-25 21:19, 08-26 00:19, 08-26 03:19). Дискриминатор из #2686/#2764 перестаёт различать причину именно там, где это нужнее всего. ### Это НЕ регресс переезда Проверял специально: прогоны 24.08 на Beget (18:17, 21:17) уже `banned`/`platform` с теми же `blocked=6..10`. Хроника, а не следствие смены хоста. ### Побочно закрывает открытый вопрос из #3059 Там опасались, что после переезда браузерный сбор Авито встанет совсем (привязка прокси к старому egress-IP). Судя по этим прогонам — **браузерный режим с Poincare работает**: `browser-mode` отрабатывает, 6 из 20 карточек обогащаются, то есть и сайдкар, и прокси живы. Отваливается не транспорт, а сам источник софт-блоком.
Collaborator

Поправка к замеру: «обогащено 0» — это про не ту колонку. Реально идёт ~160 карточек в сутки

Снято на живой базе Poincare 26.08 09:5x UTC.

Замер в комментарии выше считал total_seen. avito_detail_backfill этой колонки не пишет вообще — она остаётся 0 у всех 36 прогонов за 7 дней. Свой счётчик джоб кладёт в counters, и ключ там называется enriched, а не detail_enriched (backend/app/tasks/avito_detail_backfill.py:171), поэтому запрос по counters->>'detail_enriched' тоже даёт 0.

Что в counters на самом деле (последние прогоны):

26.08 06:19 banned  enriched=3   attempted=10  blocked=7
26.08 03:19 banned  enriched=6   attempted=20  blocked=14
26.08 00:19 banned  enriched=2   attempted=10  blocked=8
25.08 21:19 banned  enriched=35  attempted=71  blocked=33
25.08 09:18 banned  enriched=61  attempted=100 blocked=39

Независимая проверка по данным, а не по счётчикам прогона — listings.detail_enriched_at:

сутки обогащено карточек
21.08 176
22.08 170
23.08 161
24.08 161
25.08 116
за 7 дней 801

Всего у Авито 13 245 объявлений с деталями, без деталей — 7 814 активных.

Что это меняет и что не меняет

Не меняет: дефект из заголовка реален. HTTP 439 не обработан, 429 с бан-страницей ретраится, каждый прогон обрывается блоками (blocked 5–39 за прогон), статус banned честный.

Меняет вывод: detail-путь не мёртв, он работает урывками — примерно 160 карточек в сутки при очереди 7 814, то есть полный проход занял бы ~7 недель. Приоритет от этого не падает, но формулировка «ни одного успешного прогона, обогащено 0» приводит к неверному следствию: будто чинить надо транспорт с нуля, тогда как половина попыток проходит и вопрос в доле блоков.

Дешёвый фикс наблюдаемости (отдельно от самого дефекта)

Сейчас сводка прогона врёт по построению: total_seen=0 у джоба, который свою работу считает в другом месте. Либо писать total_seen = enriched в mark_*, либо в разборах брать counters->>'enriched'. Иначе следующий замер снова получит ноль и снова его поверит — это ровно тот случай, когда «провал и успех пишут один и тот же признак».

## Поправка к замеру: «обогащено 0» — это про не ту колонку. Реально идёт ~160 карточек в сутки Снято на живой базе Poincare 26.08 09:5x UTC. Замер в комментарии выше считал `total_seen`. `avito_detail_backfill` этой колонки **не пишет вообще** — она остаётся 0 у всех 36 прогонов за 7 дней. Свой счётчик джоб кладёт в `counters`, и ключ там называется `enriched`, а не `detail_enriched` (`backend/app/tasks/avito_detail_backfill.py:171`), поэтому запрос по `counters->>'detail_enriched'` тоже даёт 0. Что в `counters` на самом деле (последние прогоны): ``` 26.08 06:19 banned enriched=3 attempted=10 blocked=7 26.08 03:19 banned enriched=6 attempted=20 blocked=14 26.08 00:19 banned enriched=2 attempted=10 blocked=8 25.08 21:19 banned enriched=35 attempted=71 blocked=33 25.08 09:18 banned enriched=61 attempted=100 blocked=39 ``` Независимая проверка по данным, а не по счётчикам прогона — `listings.detail_enriched_at`: | сутки | обогащено карточек | |---|---:| | 21.08 | 176 | | 22.08 | 170 | | 23.08 | 161 | | 24.08 | 161 | | 25.08 | 116 | | **за 7 дней** | **801** | Всего у Авито 13 245 объявлений с деталями, без деталей — 7 814 активных. ## Что это меняет и что не меняет **Не меняет:** дефект из заголовка реален. HTTP 439 не обработан, 429 с бан-страницей ретраится, каждый прогон обрывается блоками (`blocked` 5–39 за прогон), статус `banned` честный. **Меняет вывод:** detail-путь не мёртв, он работает урывками — примерно 160 карточек в сутки при очереди 7 814, то есть полный проход занял бы ~7 недель. Приоритет от этого не падает, но формулировка «ни одного успешного прогона, обогащено 0» приводит к неверному следствию: будто чинить надо транспорт с нуля, тогда как половина попыток проходит и вопрос в доле блоков. ## Дешёвый фикс наблюдаемости (отдельно от самого дефекта) Сейчас сводка прогона врёт по построению: `total_seen=0` у джоба, который свою работу считает в другом месте. Либо писать `total_seen = enriched` в `mark_*`, либо в разборах брать `counters->>'enriched'`. Иначе следующий замер снова получит ноль и снова его поверит — это ровно тот случай, когда «провал и успех пишут один и тот же признак».
Author
Owner

Поправка к моему замеру выше: пункт 1 был неверен

Написал, что «работа делается, но в метрику не попадает» и что мониторинг по total_seen врёт. Проверил countersчисло никуда не теряется:

08-26 06:19  total_seen=0  counters={"gone":0,"failed":0,"blocked":7,"enriched":3,"attempted":10,"duration_sec":492}
08-26 03:19  total_seen=0  counters={"gone":0,"failed":0,"blocked":14,"enriched":6,"attempted":20,"duration_sec":304}

Ноль в колонке total_seen — это её DEFAULT, а не потерянный результат: задача сообщает свой итог ключом enriched в counters. И сторож нулевого результата читает именно counters, а не колонку — ровно ради различения «не измерено» от «измерено, ноль» это и переделывали в #2703 (_run_result_count возвращает None, а не 0). Так что расхождения «счётчик против факта» нет, приношу извинения за шум.

Что остаётся правдой и, возможно, важнее

enriched не входит в _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / succeeded), то есть avito_detail_backfill попадает в осознанную слепую зону сторожа — код даже логирует это через _warn_source_has_no_result_metric. Практический итог: 35 подряд неуспешных прогонов за 7 дней не поднимут zero-result-алерт, потому что судить по этому источнику сторожу нечем.

Добавлять enriched в список я не предлагаю с ходу: в комментарии к _RESULT_COUNTER_KEYS подробно разобрано, почему туда не берут rows_inserted и processed — у многих источников ноль это ЗДОРОВЫЙ ответ, и лишний ключ создаёт вечный ложный стрик. Для avito_detail_backfill вопрос ровно тот же: enriched=0 при пустой очереди — норма или отказ. Это решение о семантике, поэтому оставляю его владельцу, а не правлю молча.

Остальные наблюдения из комментария выше в силе: 35 прогонов, ноль успешных; съезд ban_kind в unknown; и то, что это не регресс переезда.

## Поправка к моему замеру выше: пункт 1 был неверен Написал, что «работа делается, но в метрику не попадает» и что мониторинг по `total_seen` врёт. Проверил `counters` — **число никуда не теряется**: ``` 08-26 06:19 total_seen=0 counters={"gone":0,"failed":0,"blocked":7,"enriched":3,"attempted":10,"duration_sec":492} 08-26 03:19 total_seen=0 counters={"gone":0,"failed":0,"blocked":14,"enriched":6,"attempted":20,"duration_sec":304} ``` Ноль в колонке `total_seen` — это её DEFAULT, а не потерянный результат: задача сообщает свой итог ключом `enriched` в `counters`. И сторож нулевого результата читает именно `counters`, а не колонку — ровно ради различения «не измерено» от «измерено, ноль» это и переделывали в #2703 (`_run_result_count` возвращает `None`, а не 0). Так что расхождения «счётчик против факта» нет, приношу извинения за шум. ### Что остаётся правдой и, возможно, важнее `enriched` **не входит** в `_RESULT_COUNTER_KEYS` (`total_seen` / `lots_fetched` / `unique_fetched` / `succeeded`), то есть `avito_detail_backfill` попадает в осознанную слепую зону сторожа — код даже логирует это через `_warn_source_has_no_result_metric`. Практический итог: **35 подряд неуспешных прогонов за 7 дней не поднимут zero-result-алерт**, потому что судить по этому источнику сторожу нечем. Добавлять `enriched` в список я не предлагаю с ходу: в комментарии к `_RESULT_COUNTER_KEYS` подробно разобрано, почему туда не берут `rows_inserted` и `processed` — у многих источников ноль это ЗДОРОВЫЙ ответ, и лишний ключ создаёт вечный ложный стрик. Для `avito_detail_backfill` вопрос ровно тот же: `enriched=0` при пустой очереди — норма или отказ. Это решение о семантике, поэтому оставляю его владельцу, а не правлю молча. Остальные наблюдения из комментария выше в силе: 35 прогонов, ноль успешных; съезд `ban_kind` в `unknown`; и то, что это **не** регресс переезда.
Author
Owner

Вторая поправка: пункт 2 про ban_kind тоже неверен

Написал, что ban_kind «поехал в unknown» после 25.08 и дискриминатор перестаёт различать причину. Неверно дважды.

Во-первых, сдвига нет. Я смотрел на шесть последних строк — классическая ошибка малой выборки. Распределение по дням:

день platform unknown infra
08-20 1 1 1
08-21 3 2
08-22 4 3
08-23 6 4
08-24 8 2
08-25 7 3
08-26 (неполный) 1 3

unknown есть каждый день задолго до переезда, доли колеблются.

Во-вторых, unknown — это не поломка, а починка. Миграция 234_scrape_runs_ban_kind_unknown.sql (#2764) завела это значение специально, и по прямо противоположной причине, чем я предположил: до неё финализатор backfill-задач (mark_backfill_finished, #2674) ставил platform по умолчанию, не устанавливая причину вовсе — то есть выдавал догадку за доказательство и делал её неотличимой от десяти строк с реально доказанным «Avito SERP firewall». Замер в шапке миграции это и фиксирует.

То есть unknown = «причина не установлена» — честная метка, и её появление означает, что дискриминатор работает как задумано, а не деградирует.


Итого из трёх моих наблюдений выше подтвердилось одно: 35 прогонов за 7 дней, ноль успешных, и это не регресс переезда. Два остальных снимаю. Дальше буду сверять гипотезу с историей и с исходным замыслом кода до публикации, а не после.

## Вторая поправка: пункт 2 про `ban_kind` тоже неверен Написал, что `ban_kind` «поехал в `unknown`» после 25.08 и дискриминатор перестаёт различать причину. Неверно дважды. **Во-первых, сдвига нет.** Я смотрел на шесть последних строк — классическая ошибка малой выборки. Распределение по дням: | день | platform | unknown | infra | |---|---|---|---| | 08-20 | 1 | 1 | 1 | | 08-21 | 3 | 2 | | | 08-22 | 4 | 3 | | | 08-23 | 6 | 4 | | | 08-24 | 8 | 2 | | | 08-25 | 7 | 3 | | | 08-26 (неполный) | 1 | 3 | | `unknown` есть каждый день задолго до переезда, доли колеблются. **Во-вторых, `unknown` — это не поломка, а починка.** Миграция `234_scrape_runs_ban_kind_unknown.sql` (#2764) завела это значение специально, и по прямо противоположной причине, чем я предположил: до неё финализатор backfill-задач (`mark_backfill_finished`, #2674) ставил `platform` **по умолчанию, не устанавливая причину вовсе** — то есть выдавал догадку за доказательство и делал её неотличимой от десяти строк с реально доказанным «Avito SERP firewall». Замер в шапке миграции это и фиксирует. То есть `unknown` = «причина не установлена» — честная метка, и её появление означает, что дискриминатор работает как задумано, а не деградирует. --- Итого из трёх моих наблюдений выше подтвердилось одно: **35 прогонов за 7 дней, ноль успешных**, и это не регресс переезда. Два остальных снимаю. Дальше буду сверять гипотезу с историей и с исходным замыслом кода до публикации, а не после.
Collaborator

Наблюдаемость из комментария выше починена в PR #3096 (на проде с 26.08 ~07:20 UTC, код в контейнерах проверен): _column_counts берёт enriched фолбэком для витринной колонки total_seen — только для колонки, НЕ для zero-result-стрика (там догнавший очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую ревью #2703 уже выкинуло из списка rows_inserted; первая версия правки в общий список была завёрнута тестами #2703).

Приёмка с датой: после ближайшего прогона avito_detail_backfill (сегодня 09:19 UTC) при counters.enriched > 0 колонка total_seen обязана стать равной ему. Проверю сегодня же; если прогон опять чисто banned с enriched=0 — критерий переносится на первый прогон с enriched>0.

Сам дефект задачи (439/QRATOR, обрывы блоками) этим не тронут — остаётся.

Наблюдаемость из комментария выше починена в PR #3096 (на проде с 26.08 ~07:20 UTC, код в контейнерах проверен): `_column_counts` берёт `enriched` фолбэком для витринной колонки `total_seen` — только для колонки, НЕ для zero-result-стрика (там догнавший очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую ревью #2703 уже выкинуло из списка `rows_inserted`; первая версия правки в общий список была завёрнута тестами #2703). **Приёмка с датой**: после ближайшего прогона `avito_detail_backfill` (сегодня 09:19 UTC) при `counters.enriched > 0` колонка `total_seen` обязана стать равной ему. Проверю сегодня же; если прогон опять чисто banned с enriched=0 — критерий переносится на первый прогон с enriched>0. Сам дефект задачи (439/QRATOR, обрывы блоками) этим не тронут — остаётся.
Collaborator

Приёмка наблюдаемости прошла (26.08, первый пост-деплойный прогон)

Критерий из комментария выше: «при counters.enriched > 0 колонка total_seen обязана стать равной ему».

run 4961 | 09:20 UTC | banned | total_seen = 1 | counters.enriched = 1   ✓

До фикса total_seen был 0 у всех 36 прогонов недели независимо от реальной работы. Теперь сводка прогона и данные (listings.detail_enriched_at) считают одно и то же — следующий замер по этой задаче не сможет повторить ошибку «обогащено 0».

Наблюдаемость закрыта. Сам дефект задачи — 439 не обработан, прогоны рвутся блоками (у 4961 снова banned) — остаётся открытым, это трек #3045/QRATOR.

## Приёмка наблюдаемости прошла (26.08, первый пост-деплойный прогон) Критерий из комментария выше: «при `counters.enriched > 0` колонка `total_seen` обязана стать равной ему». ``` run 4961 | 09:20 UTC | banned | total_seen = 1 | counters.enriched = 1 ✓ ``` До фикса `total_seen` был 0 у всех 36 прогонов недели независимо от реальной работы. Теперь сводка прогона и данные (`listings.detail_enriched_at`) считают одно и то же — следующий замер по этой задаче не сможет повторить ошибку «обогащено 0». Наблюдаемость закрыта. Сам дефект задачи — 439 не обработан, прогоны рвутся блоками (у 4961 снова `banned`) — остаётся открытым, это трек #3045/QRATOR.
Author
Owner

Почему бэкфилл даёт 0 из 37: обработка кодов уже есть, но не на том пути

Разобрал причину. Она не в 403/429/439 — их обработку уже добавили.

Вся логика восстановления живёт в curl_cffi-ветке

В providers/avito/detail.py есть развитый механизм: 5 ретраев на 403, 8 на 429, плюс _AVITO_DETAIL_429_RECONNECT_RETRIES через эфемерную сессию со свежим exit-IP. HTTP 439 обрабатывается по 403-пути. Всё это работает.

А бэкфилл идёт в browser-mode, где нет ничего

Ошибка прогонов подписана прямо: Avito detail firewall/soft-block (browser-mode). В браузерной ветке (detail.py:496-534) при распознанной странице-челлендже сразу бросается AvitoBlockedErrorбез единой повторной попытки и без смены exit-IP.

Почему одна страница-челлендж убивает весь прогон

BrowserFetcher держит аренду прокси на весь прогон, часы, а не на запрос — это сделано намеренно (комментарий на browser_fetcher.py:286). Смена аренды есть (_report_fetch_result_lease_fail_streak → release+acquire), но срабатывает только на отказах сайдкара.

Страница-челлендж приходит как обычный HTTP 200 с телом-заглушкой. Для фетчера это успех, счётчик отказов не растёт, аренда не меняется. Дальше весь прогон продолжает ходить с того же сожжённого exit-IP — отсюда blocked=14, обогащено 6 из 20 и ноль успешных прогонов подряд.

Пул при этом здоров — дело не в нехватке адресов

Проверил, потому что напрашивалась версия «прокси кончились»:

id метка оператор ротация отказов браузерных сбоев
1 asocks-residential-1 ASocks residential есть 0 0
9 asocks-mobile-1 ASocks mobile есть 0 0
10 asocks-mobile-2 ASocks mobile есть 0 0
11 asocks-mobile-3 ASocks mobile есть 0 0

Все четыре включены и здоровы, у всех задан rotate_url. Для avito доступны 3 из 4 (один забанен с 21.08). То есть свежие адреса есть, до них просто некому дотянуться из браузерной ветки.

Что предлагаю

Дать браузерному пути то, что уже есть у cffi — реакцию на челлендж:

  1. Публичный метод у BrowserFetcher вида «страница пришла 200, но это заглушка защиты» — внутри он делает то же, что и отказ сайдкара (_report_fetch_result(ok=False)), то есть считает exit-IP сожжённым.
  2. Ретрай в detail.py после смены аренды, по образцу cffi-ветки, с тем же потолком попыток.

Нюанс, который стоит решить осознанно: rotate_url в ките сейчас не используется — по комментарию в serp.py:552 его сняли в #2616 шаг 2 в пользу пуловой ротации. Значит выбор такой: либо менять запись пула (адресов немного, но они ротируемые), либо вернуть дёрганье rotate_url для свежего exit-IP внутри той же записи. Второе точнее бьёт в цель, но это возврат к снятому механизму — поэтому предлагаю, а не делаю молча.

Правка затрагивает BrowserFetcher, которым пользуются и другие провайдеры, поэтому не берусь за неё в одиночку без подтверждения направления.

## Почему бэкфилл даёт 0 из 37: обработка кодов уже есть, но не на том пути Разобрал причину. Она не в 403/429/439 — их обработку уже добавили. ### Вся логика восстановления живёт в curl_cffi-ветке В `providers/avito/detail.py` есть развитый механизм: 5 ретраев на 403, 8 на 429, плюс `_AVITO_DETAIL_429_RECONNECT_RETRIES` через эфемерную сессию со свежим exit-IP. HTTP 439 обрабатывается по 403-пути. Всё это работает. ### А бэкфилл идёт в browser-mode, где нет ничего Ошибка прогонов подписана прямо: `Avito detail firewall/soft-block (browser-mode)`. В браузерной ветке (`detail.py:496-534`) при распознанной странице-челлендже сразу бросается `AvitoBlockedError` — **без единой повторной попытки и без смены exit-IP**. ### Почему одна страница-челлендж убивает весь прогон `BrowserFetcher` держит аренду прокси **на весь прогон, часы**, а не на запрос — это сделано намеренно (комментарий на `browser_fetcher.py:286`). Смена аренды есть (`_report_fetch_result` → `_lease_fail_streak` → release+acquire), но срабатывает только на отказах сайдкара. **Страница-челлендж приходит как обычный HTTP 200** с телом-заглушкой. Для фетчера это успех, счётчик отказов не растёт, аренда не меняется. Дальше весь прогон продолжает ходить с того же сожжённого exit-IP — отсюда `blocked=14, обогащено 6 из 20` и ноль успешных прогонов подряд. ### Пул при этом здоров — дело не в нехватке адресов Проверил, потому что напрашивалась версия «прокси кончились»: | id | метка | оператор | ротация | отказов | браузерных сбоев | |---|---|---|---|---|---| | 1 | asocks-residential-1 | ASocks residential | есть | 0 | 0 | | 9 | asocks-mobile-1 | ASocks mobile | есть | 0 | 0 | | 10 | asocks-mobile-2 | ASocks mobile | есть | 0 | 0 | | 11 | asocks-mobile-3 | ASocks mobile | есть | 0 | 0 | Все четыре включены и здоровы, у всех задан `rotate_url`. Для avito доступны 3 из 4 (один забанен с 21.08). То есть **свежие адреса есть, до них просто некому дотянуться из браузерной ветки**. ### Что предлагаю Дать браузерному пути то, что уже есть у cffi — реакцию на челлендж: 1. **Публичный метод у `BrowserFetcher`** вида «страница пришла 200, но это заглушка защиты» — внутри он делает то же, что и отказ сайдкара (`_report_fetch_result(ok=False)`), то есть считает exit-IP сожжённым. 2. **Ретрай в `detail.py`** после смены аренды, по образцу cffi-ветки, с тем же потолком попыток. Нюанс, который стоит решить осознанно: `rotate_url` в ките сейчас не используется — по комментарию в `serp.py:552` его сняли в #2616 шаг 2 в пользу пуловой ротации. Значит выбор такой: либо менять запись пула (адресов немного, но они ротируемые), либо вернуть дёрганье `rotate_url` для свежего exit-IP внутри той же записи. Второе точнее бьёт в цель, но это возврат к снятому механизму — поэтому предлагаю, а не делаю молча. Правка затрагивает `BrowserFetcher`, которым пользуются и другие провайдеры, поэтому не берусь за неё в одиночку без подтверждения направления.
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#3044
No description provided.