fix(tradein/scrapers): egress по источнику из пула с учётом банов, а не статичный env #2831

Merged
lekss361 merged 1 commit from fix/tradein-egress into main 2026-08-11 06:29:49 +00:00
Owner

Проблема (корень инцидента 2026-08-10)

SCRAPER_PROXY_URL был единственным egress для прямых curl_cffi/httpx-сессий в app/services/* и app/tasks/* — тех, что строятся мимо proxy_pool и scraper_kit-оркестрации.

Он указывал на asocks-residential-1 — узел, который в scrape_proxy_source_bans был забанен и для avito, и для cian до 13.08. При этом в пуле стояли три здоровых мобильных узла.

Система аккуратно вела учёт банов и ротации в БД — и полностью игнорировала его при фактическом выходе в сеть. Забаненный узел использовался месяц. Снаружи это выглядело как «Cian: плохие куки» и «Avito: IP banned», то есть маскировалось под проблему доступов; на диагностику ушёл час живого инцидента.

Summary

  • proxy_egress.resolve_proxy_url(db, source) — read-only выбор узла: enabled, consecutive_fails < MAX_CONSECUTIVE_FAILS (тот же порог, что у acquire), нет активного бана для этого источника; при равенстве — свежий last_ok_at. Без lease: вызывающие делают короткие одноразовые fetch'и без гарантированного release на всех путях выхода, занимать под них lease значило бы дырявить пул фантомно занятыми узлами.
  • Fail-closed при исчерпании (принцип #2616): пул не пуст, но все узлы отсеяны → ProxyPoolExhaustedError и отказ, без обхода через env. Иначе в худший момент — скоординированная блокировка, тот же класс события, что вызвал инцидент, — система возвращалась бы ровно к исходному поведению.
  • Пустой пул (dev/staging без БД-пула) → env с warning, это легитимно. Ошибка БД трактуется как «пул пуст», а не как исчерпание: временный сбой соединения не должен останавливать сбор.
  • Наблюдаемость: выбранный узел логируется как id + label/host:port — никогда не URL целиком, он несёт credentials. Тексты «пуст» и «FAIL-CLOSED» различаются, у второго уровень error с разбивкой banned_for_source / unhealthy_or_disabled. Именно отсутствие такого сигнала спрятало проблему на месяц.

Переведены: cian_session, yandex_address_backfill, avito_detail_backfill, yandex_detail_backfill. browser/server.py намеренно не тронут — у него свой контракт через env.

Ситуация Поведение Лог
Найден здоровый небаненный узел отдаём его url info
Пул пуст (0 строк) fallback на env; пусто и там → прямое подключение warning «пуст»
Пул не пуст, все отсеяны ProxyPoolExhaustedError, env НЕ используется error «FAIL-CLOSED»
Ошибка БД лечим как «пул пуст» → env warning

Test plan

  • Полный прогон: 4211 passed, 18 skipped (+14 к базе 4197)
  • ruff check / ruff format — чисто по всем 9 файлам, pre-commit зелёный
  • Ключевой тест воспроизводит инцидент: env задан, все узлы забанены → отказ, env не используется
  • Тесты на различие текстов/уровней логов, на ошибку БД → fallback, на изоляцию банов между источниками
  • Проверено: полный URL с credentials не попадает ни в один лог
  • Code-review в свежем контексте — блокеров нет; замечание про fail-open отработано (это и есть fail-closed выше)
  • Post-deploy: smoke Cian (verify через пул) + прогон avito_city_sweep

Хвосты

Вынесены в #2830: cian_price_history.py всё ещё ходит мимо пула, а /admin/scraper/health показывает статичный узел вместо фактически выбираемого.

## Проблема (корень инцидента 2026-08-10) `SCRAPER_PROXY_URL` был **единственным** egress для прямых curl_cffi/httpx-сессий в `app/services/*` и `app/tasks/*` — тех, что строятся мимо `proxy_pool` и `scraper_kit`-оркестрации. Он указывал на `asocks-residential-1` — узел, который в `scrape_proxy_source_bans` был забанен **и для avito, и для cian** до 13.08. При этом в пуле стояли три здоровых мобильных узла. Система аккуратно вела учёт банов и ротации в БД — и полностью игнорировала его при фактическом выходе в сеть. Забаненный узел использовался месяц. Снаружи это выглядело как «Cian: плохие куки» и «Avito: IP banned», то есть маскировалось под проблему доступов; на диагностику ушёл час живого инцидента. ## Summary - **`proxy_egress.resolve_proxy_url(db, source)`** — read-only выбор узла: `enabled`, `consecutive_fails < MAX_CONSECUTIVE_FAILS` (тот же порог, что у `acquire`), нет активного бана для этого источника; при равенстве — свежий `last_ok_at`. **Без lease**: вызывающие делают короткие одноразовые fetch'и без гарантированного `release` на всех путях выхода, занимать под них lease значило бы дырявить пул фантомно занятыми узлами. - **Fail-closed при исчерпании** (принцип #2616): пул не пуст, но все узлы отсеяны → `ProxyPoolExhaustedError` и отказ, **без обхода через env**. Иначе в худший момент — скоординированная блокировка, тот же класс события, что вызвал инцидент, — система возвращалась бы ровно к исходному поведению. - Пустой пул (dev/staging без БД-пула) → env с `warning`, это легитимно. **Ошибка БД** трактуется как «пул пуст», а не как исчерпание: временный сбой соединения не должен останавливать сбор. - **Наблюдаемость**: выбранный узел логируется как id + label/host:port — никогда не URL целиком, он несёт credentials. Тексты «пуст» и «FAIL-CLOSED» различаются, у второго уровень `error` с разбивкой `banned_for_source` / `unhealthy_or_disabled`. Именно отсутствие такого сигнала спрятало проблему на месяц. Переведены: `cian_session`, `yandex_address_backfill`, `avito_detail_backfill`, `yandex_detail_backfill`. `browser/server.py` намеренно не тронут — у него свой контракт через env. | Ситуация | Поведение | Лог | |---|---|---| | Найден здоровый небаненный узел | отдаём его url | `info` | | Пул пуст (0 строк) | fallback на env; пусто и там → прямое подключение | `warning` «пуст» | | Пул не пуст, все отсеяны | **`ProxyPoolExhaustedError`**, env НЕ используется | `error` «FAIL-CLOSED» | | Ошибка БД | лечим как «пул пуст» → env | `warning` | ## Test plan - [x] Полный прогон: **4211 passed, 18 skipped** (+14 к базе 4197) - [x] `ruff check` / `ruff format` — чисто по всем 9 файлам, pre-commit зелёный - [x] Ключевой тест воспроизводит инцидент: env задан, все узлы забанены → отказ, env **не** используется - [x] Тесты на различие текстов/уровней логов, на ошибку БД → fallback, на изоляцию банов между источниками - [x] Проверено: полный URL с credentials не попадает ни в один лог - [x] Code-review в свежем контексте — блокеров нет; замечание про fail-open отработано (это и есть fail-closed выше) - [ ] Post-deploy: smoke Cian (verify через пул) + прогон avito_city_sweep ## Хвосты Вынесены в **#2830**: `cian_price_history.py` всё ещё ходит мимо пула, а `/admin/scraper/health` показывает статичный узел вместо фактически выбираемого.
lekss361 added 1 commit 2026-08-11 06:23:04 +00:00
fix(tradein/scrapers): egress по источнику из пула с учётом банов, а не статичный env
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m58s
276f544e66
Прод-инцидент 2026-08-10: SCRAPER_PROXY_URL был единственным egress для
прямых curl_cffi/httpx-сессий в app/services/* и app/tasks/*, идущих мимо
proxy_pool и scraper_kit-оркестрации. Он указывал на asocks-residential-1 —
узел, забаненный в scrape_proxy_source_bans и для avito, и для cian до
13.08, при трёх здоровых мобильных узлах в пуле.

Система вела учёт банов в БД, но фактический выход в сеть про эту таблицу
не знал и месяц ходил через забаненный узел. Снаружи это выглядело как
«Cian: плохие куки» и «Avito: IP banned» — то есть маскировалось под
проблему доступов.

- proxy_egress.resolve_proxy_url(db, source) — read-only выбор узла:
  enabled, consecutive_fails < MAX_CONSECUTIVE_FAILS, нет активного бана
  для этого источника; при равенстве — свежий last_ok_at. Без lease:
  вызывающие делают короткие одноразовые fetch'и без гарантии release.
- fail-closed при исчерпании (принцип #2616): пул НЕ пуст, но все узлы
  отсеяны → ProxyPoolExhaustedError и отказ, без обхода через env. Иначе
  в худший момент система возвращалась бы ровно к поведению, вызвавшему
  инцидент. Пустой пул (dev без БД) → env с warning, это легитимно.
  Ошибка БД трактуется как «пул пуст», а не как исчерпание, — временный
  сбой соединения не должен останавливать сбор.
- Наблюдаемость: выбранный узел логируется (id + label/host:port, никогда
  не URL целиком — он несёт credentials); тексты «пуст» и «FAIL-CLOSED»
  различаются, у второго уровень error. Отсутствие такого сигнала и
  спрятало проблему на месяц.
- Переведены cian_session, yandex_address_backfill, avito_detail_backfill,
  yandex_detail_backfill. browser/server.py намеренно не тронут — у него
  свой контракт через env.

Хвосты вне этого PR: #2830.
lekss361 merged commit f2cbd76ae0 into main 2026-08-11 06:29:49 +00:00
lekss361 deleted branch fix/tradein-egress 2026-08-11 06:29:49 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2831
No description provided.