tradein/domclick: пустой пул прокси и таймаут навигации считаются блоками площадки и вместе рвут добор карточек #3283

Closed
opened 2026-08-30 11:35:21 +00:00 by lekss361 · 1 comment
Owner

Прогон 5388 (30.08, 10:58–11:03) остановлен по правилу «3 блока подряд», обогатив ноль карточек из трёх попыток. Из трёх засчитанных блоков отказом площадки был один.

Что засчиталось блоками

# что произошло kind площадка вообще отвечала?
1 TimeoutError: Page.goto: Timeout 60000ms exceeded unknown запрос ушёл, ответа нет
2 no proxy available for provider='domclick' (pool empty in prod) unknown запрос не отправлялся
3 BanPageDetectedError: статический отказ площадки, http=401 platform да, отказ

Лог абортa сам печатает расклад: диагнозы: {'unknown': 2, 'platform': 1}. То есть информация о том, что две трети «блоков» блоками не были, у кода есть — она просто не участвует в решении об остановке.

Почему так вышло

fetch_detail в providers/domclick/detail.py заворачивает except Exception (таймаут, 5xx, транспорт) в DomClickBlockedError. Это сделано осознанно и с оговоркой в комментарии — пулу такой «блок» намеренно НЕ репортится, чтобы не путать сеть с баном. Но оркестратор в domclick_detail_backfill.py ловит DomClickBlockedError одной веткой и на каждую увеличивает consecutive_blocks; на третьей — ABORT.

Отдельно стоит случай №2. no proxy available — это состояние НАШЕЙ инфраструктуры: пул пуст, потому что предыдущий узел только что отпущен, а остальные заняты или в бане. Запрос к Домклику не отправлялся вовсе. Считать это сигналом площадки нельзя ни при каком толковании.

Почему это заметно именно сейчас

Пул из четырёх узлов и так узкий, а сегодня к этому добавилось: обрыв прогона деплоем оставляет неотпущенный lease на 30 минут (STALE_LEASE_MINUTES), и следующий запуск утыкается в пустой пул. Дальше срабатывает описанная выше цепочка, и прогон умирает, не начавшись.

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

  1. NoProxyAvailable не считать блоком вообще. Это не «источник отказал», а «нам нечем ходить». Правильная реакция — завершить прогон отдельным статусом (нечем работать), а не начислять блок.
  2. Порог считать по kind. Обрывать прогон по трём подряд platform-блокам; unknown (таймаут, 5xx, транспорт) логировать и считать как failed, ровно как уже сделано для DomClickParseError — тот честно помечен «schema drift, not a block» и счётчик блоков не трогает.

Второй пункт — не новое правило, а распространение уже принятого в этом же файле на соседний случай.

Родня

Тот же класс ошибки, что #3272 (совпадение маркера блока внутри описания квартиры) — небло́чное состояние засчитывается блоком, и цена ошибки несимметрична: ложный блок стоит оборванного прогона и часов простоя узла, пропущенный настоящий — одного лишнего запроса.

Смежно с #3246 (report_ban банит живой узел на 6 часов).

Прогон 5388 (30.08, 10:58–11:03) остановлен по правилу «3 блока подряд», обогатив ноль карточек из трёх попыток. Из трёх засчитанных блоков **отказом площадки был один**. ## Что засчиталось блоками | # | что произошло | kind | площадка вообще отвечала? | |---|---|---|---| | 1 | `TimeoutError: Page.goto: Timeout 60000ms exceeded` | `unknown` | запрос ушёл, ответа нет | | 2 | `no proxy available for provider='domclick' (pool empty in prod)` | `unknown` | **запрос не отправлялся** | | 3 | `BanPageDetectedError: статический отказ площадки`, http=401 | `platform` | да, отказ | Лог абортa сам печатает расклад: `диагнозы: {'unknown': 2, 'platform': 1}`. То есть информация о том, что две трети «блоков» блоками не были, у кода есть — она просто не участвует в решении об остановке. ## Почему так вышло `fetch_detail` в `providers/domclick/detail.py` заворачивает `except Exception` (таймаут, 5xx, транспорт) в `DomClickBlockedError`. Это сделано осознанно и с оговоркой в комментарии — пулу такой «блок» намеренно НЕ репортится, чтобы не путать сеть с баном. Но оркестратор в `domclick_detail_backfill.py` ловит `DomClickBlockedError` одной веткой и на каждую увеличивает `consecutive_blocks`; на третьей — `ABORT`. Отдельно стоит случай №2. `no proxy available` — это состояние НАШЕЙ инфраструктуры: пул пуст, потому что предыдущий узел только что отпущен, а остальные заняты или в бане. Запрос к Домклику не отправлялся вовсе. Считать это сигналом площадки нельзя ни при каком толковании. ## Почему это заметно именно сейчас Пул из четырёх узлов и так узкий, а сегодня к этому добавилось: обрыв прогона деплоем оставляет неотпущенный lease на 30 минут (`STALE_LEASE_MINUTES`), и следующий запуск утыкается в пустой пул. Дальше срабатывает описанная выше цепочка, и прогон умирает, не начавшись. ## Предлагаемая правка 1. **`NoProxyAvailable` не считать блоком вообще.** Это не «источник отказал», а «нам нечем ходить». Правильная реакция — завершить прогон отдельным статусом (нечем работать), а не начислять блок. 2. **Порог считать по `kind`.** Обрывать прогон по трём подряд `platform`-блокам; `unknown` (таймаут, 5xx, транспорт) логировать и считать как `failed`, ровно как уже сделано для `DomClickParseError` — тот честно помечен «schema drift, not a block» и счётчик блоков не трогает. Второй пункт — не новое правило, а распространение уже принятого в этом же файле на соседний случай. ## Родня Тот же класс ошибки, что #3272 (совпадение маркера блока внутри описания квартиры) — небло́чное состояние засчитывается блоком, и цена ошибки несимметрична: ложный блок стоит оборванного прогона и часов простоя узла, пропущенный настоящий — одного лишнего запроса. Смежно с #3246 (`report_ban` банит живой узел на 6 часов).
Author
Owner

Сделано в двух PR, оба в проде (образ e13d2474, ревизия 189ae63e, контейнеры пересозданы 30.08 20:34 UTC).

PR #3286 — оба пункта тикета:

  1. NoProxyAvailableError больше не блок: _caused_by_empty_pool() разбирает цепочку __cause__/__context__ и прогон завершается отдельной причиной, а не начислением блока.
  2. Порог считается по природе исключения, а не по HTTP-статусу. Различать по статусу нельзя — настоящий QRATOR-челлендж приходит без статуса либо под 200, то есть неотличим от таймаута навигации. Зато природа различима, и разделение уже проведено в самом fetch_detail: ветка except SidecarBanPageError и parse_detail_html — генуинный отказ; ветка except Exception с raise ... from exc — транспорт. Транспортные сбои уходят в counters.failed и в отдельный счётчик consecutive_soft со своим порогом (10), к max_consecutive_blocks не прикасаются.

PR #3287 — правка ошибки в первом PR, найденная прогоном 5406. SidecarBanPageError объявлен как class SidecarBanPageError(httpx.HTTPStatusError), поэтому проверка на httpx.HTTPError забирала себе генуинные отказы площадки и они переставали считаться блоками вовсе. Порядок проверок исправлен, премиса закреплена тестом test_sidecar_ban_page_is_a_subclass_of_httpx_error — если наследование когда-нибудь изменят, тест скажет об этом раньше прода.

Подтверждение в проде — прогон 5427 (30.08 20:36 UTC), ровно та развилка, ради которой тикет заводился:

попытка что произошло как засчитано
1 сайдкар опознал страницу-отказ, http=401 BLOCKED #1/400 (consecutive=1, http=401, **kind=platform**)
2 пул пуст, запрос не отправлялся блок не начислен, прогон завершён

status=failed, error = «пул прокси пуст — к площадке не ходили (#3283)», counters.no_proxy_stop=1, blocked=1. Раньше обе попытки легли бы в consecutive_blocks и прогон умер бы как «забаненный площадкой».

Тесты: tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py — 14 проверок.

Что тикет НЕ чинит и чинить не должен: пул действительно пуст. На момент прогона 5427 для domclick оставался ровно один достижимый узел, и лог пула сказал об этом прямо — proxy id=9 — бан не записан: это последний узел, достижимый для source=domclick; нужны новые прокси. Это #2638, отдельная история; после правки она хотя бы называется своим именем вместо «площадка забанила».

Сделано в двух PR, оба в проде (образ `e13d2474`, ревизия `189ae63e`, контейнеры пересозданы 30.08 20:34 UTC). **PR #3286** — оба пункта тикета: 1. `NoProxyAvailableError` больше не блок: `_caused_by_empty_pool()` разбирает цепочку `__cause__`/`__context__` и прогон завершается отдельной причиной, а не начислением блока. 2. Порог считается по природе исключения, а не по HTTP-статусу. Различать по статусу нельзя — настоящий QRATOR-челлендж приходит без статуса либо под 200, то есть неотличим от таймаута навигации. Зато природа различима, и разделение уже проведено в самом `fetch_detail`: ветка `except SidecarBanPageError` и `parse_detail_html` — генуинный отказ; ветка `except Exception` с `raise ... from exc` — транспорт. Транспортные сбои уходят в `counters.failed` и в отдельный счётчик `consecutive_soft` со своим порогом (10), к `max_consecutive_blocks` не прикасаются. **PR #3287** — правка ошибки в первом PR, найденная прогоном 5406. `SidecarBanPageError` объявлен как `class SidecarBanPageError(httpx.HTTPStatusError)`, поэтому проверка на `httpx.HTTPError` забирала себе генуинные отказы площадки и они переставали считаться блоками вовсе. Порядок проверок исправлен, премиса закреплена тестом `test_sidecar_ban_page_is_a_subclass_of_httpx_error` — если наследование когда-нибудь изменят, тест скажет об этом раньше прода. **Подтверждение в проде — прогон 5427** (30.08 20:36 UTC), ровно та развилка, ради которой тикет заводился: | попытка | что произошло | как засчитано | |---|---|---| | 1 | сайдкар опознал страницу-отказ, http=401 | `BLOCKED #1/400 (consecutive=1, http=401, **kind=platform**)` | | 2 | пул пуст, запрос не отправлялся | блок **не начислен**, прогон завершён | `status=failed`, `error = «пул прокси пуст — к площадке не ходили (#3283)»`, `counters.no_proxy_stop=1`, `blocked=1`. Раньше обе попытки легли бы в `consecutive_blocks` и прогон умер бы как «забаненный площадкой». Тесты: `tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py` — 14 проверок. Что тикет НЕ чинит и чинить не должен: пул действительно пуст. На момент прогона 5427 для `domclick` оставался ровно один достижимый узел, и лог пула сказал об этом прямо — `proxy id=9 — бан не записан: это последний узел, достижимый для source=domclick; нужны новые прокси`. Это #2638, отдельная история; после правки она хотя бы называется своим именем вместо «площадка забанила».
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3283
No description provided.