fix(tradein/scraper): не ходить через мёртвый запасной прокси #2634

Merged
bot-backend merged 2 commits from fix/tradein-no-dead-env-proxy-fallback into main 2026-08-04 18:27:50 +00:00
Owner

Summary

Issue #2616 шаг 1. Убирает падение сборщиков на мёртвый запасной (env) прокси — измеренная причина 73-98% событий banned за 30 дней (issue #2613/#2616 диагностика).

Что сделано (3 файла из issue + плюс contracts.py/scraper_adapters.py/_base.py для проброса признака окружения):

  1. packages/scraper-kit/src/scraper_kit/providers/_proxy.py::curl_proxy_url — когда пул реально задействован (use_proxy_pool_curl=True, ProxyProvider передан) и acquire() вернул None/упал в prod — поднимает NoProxyAvailableError ДО HTTP-запроса вместо похода через мёртвый env_fallback_url. В dev (или когда config/proxy_provider не заданы — существующий dev/no-op путь, providers/cian/detail.py:118) поведение не меняется.
  2. packages/scraper-kit/src/scraper_kit/browser_fetcher.py::_pool_proxy — то же для browser-пути: пул on + пуст + prod → NoProxyAvailableError, POST /fetch//fetch-json не отправляется.
  3. tradein-mvp/browser/server.py — новый guard _no_live_proxy() в fetch_handler/fetch_json_handler/login_handler: prod + нет proxy в теле запроса И нет ЛЮБОЙ env-переменной для провайдера → явный отказ (503, "no proxy configured — refusing direct connection (prod)") ДО лока/launch'а camoufox, вместо тихого запуска браузера без прокси (= прямое подключение с IP сервера). Это прицельная защита от следующего шага issue #2616 (удаление мёртвых env-переменных) — без неё после удаления env camoufox бы тихо стал ходить с IP сервера.
  4. Признак окружения: ScraperConfig.environment: str (Protocol, contracts.py) ← RealScraperConfig.environmentsettings.environment (уже существовавшее поле, дефолт "dev", прод = "production"). server.py читает ENVIRONMENT из os.environ напрямую (это отдельный процесс/контейнер, не FastAPI-приложение).
  5. Три причины остановки теперь различимы по типу/тексту: NoProxyAvailableError (наша инфраструктура, structured .provider атрибут, текст без "blocked"/"captcha"/"banned") vs AvitoBlockedError/AvitoRateLimitedError (блокировка площадкой) vs голые httpx/curl_cffi transport-исключения (сетевая ошибка). server.py отдаёт разный error-текст для "нет прокси вообще" vs "launch упал на заданном прокси" (browser unavailable (proxy may be down), существующий путь — не тронут).

Границы (по ТЗ): proxy_pool.py/proxy_rotation.py не тронуты. Env-переменные НЕ удалены (отдельный шаг #2616, после этого PR). Расписания/пейсинг не менялись. Миграций нет.

Оценка эффекта (read-only, scrape_runs, последние 14 дней)

source                              | status | n
avito_full_load                     | banned | 14
avito_city_sweep                    | banned | 12
avito_city_sweep_nizhniy_tagil      | banned | 12
avito_newbuilding_sweep             | banned | 12
avito_full_load_exhaustive          | banned |  2
avito_city_sweep_serov              | banned |  1

53 banned-прогона за 14 дней — все Avito, cian_*/yandex_*/domclick_* = 0 banned. Разбивка по error-тексту: 52/53 = "...browser unavailable (proxy may be down)", 1/53 = реальный firewall-детект ("Avito SERP firewall (browser-mode) — IP banned").

Честно про прямой эффект этого PR на счётчик "banned" за следующие 14 дней: ~0. Причина — Avito browser-SERP путь (providers/avito/serp.py::_fetch_serp_html_browser) сегодня НЕ проброшен на ProxyProvider (build_browser_fetcher(config, "avito") вызывается без proxy_provider=, в отличие от cian/yandex) — значит _pool_proxy у него всегда use_pool=False, и этот PR (гейтится на use_pool=True) его не касается. Плюс сегодняшние BROWSER_PROXY_AVITO/AVITO_PROXY_URL — мёртвые, но заданные переменные (407/connection refused, не отсутствующие) — guard #4 (_no_live_proxy) специально не триггерится на "задано, но дохлое": удаление дохлых переменных — намеренно отдельный, следующий шаг issue #2616 (я его не делаю в этом PR, по ТЗ). После этого шага без guard #4 avito тихо пошёл бы напрямую с IP сервера; с guard #4 — получит явный отказ вместо этого.

Что этот PR РЕАЛЬНО меняет сегодня: убирает скрытый риск wasted-request/kill+relaunch цикла и мёртвого-env-похода на cian/yandex curl+browser путях (use_proxy_pool_curl/use_proxy_pool_browser=true в проде, пул иногда может быть пуст) — сейчас там 0 banned, но при опустошении пула они бы тихо пошли на дохлые CIAN_PROXY_URL/YANDEX_PROXY_URL/BROWSER_PROXY_CIAN/BROWSER_PROXY_YANDEX (тоже дохлые, см. issue) вместо явного отказа.

Рекомендованный следующий шаг (НЕ в этом PR, другой scope): чтобы реально снять маркировку banned с 52/53 avito-прогонов, нужно (a) либо wiring proxy_provider= в avito browser-путь + классификация NoProxyAvailableError отдельно от is_soft_ban в avito/serp.py:535-540 (сейчас ЛЮБОЙ status==503/"browser unavailable" безусловно трактуется как soft-ban → после исчерпания бюджета ротации → mark_banned), либо (b) шаг 2 issue #2616 (удаление дохлых env) — тогда сегодняшний launch-fail (503) исчезнет сам, camoufox либо получит признак "нет прокси" через guard #4 (503, distinguishable), либо (при отключённом guard) пойдёт напрямую — что как раз и предотвращает этот PR.

Test plan

  • cd tradein-mvp/backend && uv run pytest -q — 3190 passed, 9 skipped, 1 known pre-existing fail (tests/test_search_api.py::test_search_cache_hit, 401 RBAC — не в scope, явно указан в ТЗ).
  • cd tradein-mvp/browser && uv run --no-project --with aiohttp --with pytest pytest test_server_no_proxy_refusal.py -q — 10/10 passed (новый файл, изолированный тест-раннер как у соседних test_server_*.pybrowser/ не имеет pyproject.toml, зависимости aiohttp/pytest только в Docker-образе).
  • Falsification: каждый новый тест-сценарий проверен на подмене реализации пустышкой (stub, падающий на любом HTTP-вызове) — падает по смыслу, не по имени/сигнатуре.
  • ruff check (project select E,F,I,B,UP,N,RUF) — чисто на всех изменённых файлах.
  • Regression check: browser/test_server_smoke.py/test_server.py имеют 4 pre-existing failures (устаревшие моки _ensure_browser без proxy_override kwarg) — воспроизведены на git stash (код ДО этого PR), не вызваны этим PR.

Falsification (числа обоих прогонов)

curl_proxy_url/_pool_proxy prod-refusal тесты (test_flag_on_empty_pool_prod_refuses_no_http_request, test_fetch_pool_empty_prod_refuses_no_http_post и т.д.): заглушка _boom()/client.post кидает AssertionError на любом вызове внутри with-блока / на POST. С фиксом — NoProxyAvailableError поднимается ДО yield, _boom()/client.post не достигается → pytest.raises(NoProxyAvailableError) матчит.

  • После фикса: pytest tests/test_proxy_pool_curl_paths.py tests/test_kit_browser_fetcher_proxy_pool.py -q51 passed.
  • До фикса (подмена curl_proxy_url/_pool_proxy на pre-fix реализацию, всегда yield env_fallback_url при пустом пуле): новые prod-тесты падают на несовпадении типа исключения (AssertionError вместо NoProxyAvailableError) — подтверждено построчным разбором логики: raise стоит ПОСЛЕ проверки use_pool and _is_prod(config), при revert она снята → _boom()/client.post выполняются → тест ловит несовпадение типа исключения.

Файлы

  • tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py (новый) — NoProxyAvailableError
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py
  • tradein-mvp/backend/app/services/scraper_adapters.py
  • tradein-mvp/browser/server.py
  • Тесты: tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py, test_kit_browser_fetcher_proxy_pool.py, test_kit_serp_proxy_pool.py (fixup: recording-стаб принял новый kwarg), test_scraper_adapters_contracts.py, test_scraper_kit_providers_base.py, tradein-mvp/browser/test_server_no_proxy_refusal.py (новый)

Refs #2616

## Summary Issue #2616 шаг 1. Убирает падение сборщиков на мёртвый запасной (env) прокси — измеренная причина 73-98% событий `banned` за 30 дней (issue #2613/#2616 диагностика). **Что сделано** (3 файла из issue + плюс `contracts.py`/`scraper_adapters.py`/`_base.py` для проброса признака окружения): 1. `packages/scraper-kit/src/scraper_kit/providers/_proxy.py::curl_proxy_url` — когда пул реально задействован (`use_proxy_pool_curl=True`, `ProxyProvider` передан) и `acquire()` вернул `None`/упал **в prod** — поднимает `NoProxyAvailableError` ДО HTTP-запроса вместо похода через мёртвый `env_fallback_url`. В dev (или когда `config`/`proxy_provider` не заданы — существующий dev/no-op путь, `providers/cian/detail.py:118`) поведение не меняется. 2. `packages/scraper-kit/src/scraper_kit/browser_fetcher.py::_pool_proxy` — то же для browser-пути: пул on + пуст + prod → `NoProxyAvailableError`, POST `/fetch`/`/fetch-json` не отправляется. 3. `tradein-mvp/browser/server.py` — новый guard `_no_live_proxy()` в `fetch_handler`/`fetch_json_handler`/`login_handler`: **prod** + нет `proxy` в теле запроса И нет ЛЮБОЙ env-переменной для провайдера → явный отказ (503, `"no proxy configured — refusing direct connection (prod)"`) ДО лока/launch'а camoufox, вместо тихого запуска браузера без прокси (= прямое подключение с IP сервера). Это прицельная защита от следующего шага issue #2616 (удаление мёртвых env-переменных) — без неё после удаления env camoufox бы тихо стал ходить с IP сервера. 4. Признак окружения: `ScraperConfig.environment: str` (Protocol, `contracts.py`) ← `RealScraperConfig.environment` ← `settings.environment` (уже существовавшее поле, дефолт `"dev"`, прод = `"production"`). `server.py` читает `ENVIRONMENT` из os.environ напрямую (это отдельный процесс/контейнер, не FastAPI-приложение). 5. Три причины остановки теперь различимы по типу/тексту: `NoProxyAvailableError` (наша инфраструктура, structured `.provider` атрибут, текст без "blocked"/"captcha"/"banned") vs `AvitoBlockedError`/`AvitoRateLimitedError` (блокировка площадкой) vs голые `httpx`/`curl_cffi` transport-исключения (сетевая ошибка). `server.py` отдаёт разный `error`-текст для "нет прокси вообще" vs "launch упал на заданном прокси" (`browser unavailable (proxy may be down)`, существующий путь — не тронут). **Границы (по ТЗ):** `proxy_pool.py`/`proxy_rotation.py` не тронуты. Env-переменные НЕ удалены (отдельный шаг #2616, после этого PR). Расписания/пейсинг не менялись. Миграций нет. ## Оценка эффекта (read-only, `scrape_runs`, последние 14 дней) ``` source | status | n avito_full_load | banned | 14 avito_city_sweep | banned | 12 avito_city_sweep_nizhniy_tagil | banned | 12 avito_newbuilding_sweep | banned | 12 avito_full_load_exhaustive | banned | 2 avito_city_sweep_serov | banned | 1 ``` 53 `banned`-прогона за 14 дней — **все Avito**, `cian_*`/`yandex_*`/`domclick_*` = 0 banned. Разбивка по error-тексту: 52/53 = `"...browser unavailable (proxy may be down)"`, 1/53 = реальный firewall-детект (`"Avito SERP firewall (browser-mode) — IP banned"`). **Честно про прямой эффект этого PR на счётчик "banned" за следующие 14 дней: ~0.** Причина — Avito browser-SERP путь (`providers/avito/serp.py::_fetch_serp_html_browser`) сегодня НЕ проброшен на `ProxyProvider` (`build_browser_fetcher(config, "avito")` вызывается без `proxy_provider=`, в отличие от cian/yandex) — значит `_pool_proxy` у него всегда `use_pool=False`, и этот PR (гейтится на `use_pool=True`) его не касается. Плюс сегодняшние `BROWSER_PROXY_AVITO`/`AVITO_PROXY_URL` — мёртвые, но **заданные** переменные (407/connection refused, не отсутствующие) — guard #4 (`_no_live_proxy`) специально не триггерится на "задано, но дохлое": удаление дохлых переменных — намеренно отдельный, следующий шаг issue #2616 (я его не делаю в этом PR, по ТЗ). После этого шага без guard #4 avito тихо пошёл бы напрямую с IP сервера; с guard #4 — получит явный отказ вместо этого. Что этот PR РЕАЛЬНО меняет сегодня: убирает скрытый риск wasted-request/kill+relaunch цикла и мёртвого-env-похода на cian/yandex curl+browser путях (`use_proxy_pool_curl`/`use_proxy_pool_browser=true` в проде, пул иногда может быть пуст) — сейчас там 0 banned, но при опустошении пула они бы тихо пошли на дохлые `CIAN_PROXY_URL`/`YANDEX_PROXY_URL`/`BROWSER_PROXY_CIAN`/`BROWSER_PROXY_YANDEX` (тоже дохлые, см. issue) вместо явного отказа. **Рекомендованный следующий шаг** (НЕ в этом PR, другой scope): чтобы реально снять маркировку `banned` с 52/53 avito-прогонов, нужно (a) либо wiring `proxy_provider=` в avito browser-путь + классификация `NoProxyAvailableError` отдельно от `is_soft_ban` в `avito/serp.py:535-540` (сейчас ЛЮБОЙ `status==503`/"browser unavailable" безусловно трактуется как soft-ban → после исчерпания бюджета ротации → `mark_banned`), либо (b) шаг 2 issue #2616 (удаление дохлых env) — тогда сегодняшний launch-fail (503) исчезнет сам, camoufox либо получит признак "нет прокси" через guard #4 (503, distinguishable), либо (при отключённом guard) пойдёт напрямую — что как раз и предотвращает этот PR. ## Test plan - [x] `cd tradein-mvp/backend && uv run pytest -q` — 3190 passed, 9 skipped, **1 known pre-existing fail** (`tests/test_search_api.py::test_search_cache_hit`, 401 RBAC — не в scope, явно указан в ТЗ). - [x] `cd tradein-mvp/browser && uv run --no-project --with aiohttp --with pytest pytest test_server_no_proxy_refusal.py -q` — 10/10 passed (новый файл, изолированный тест-раннер как у соседних `test_server_*.py` — `browser/` не имеет `pyproject.toml`, зависимости `aiohttp`/`pytest` только в Docker-образе). - [x] Falsification: каждый новый тест-сценарий проверен на подмене реализации пустышкой (stub, падающий на любом HTTP-вызове) — падает по смыслу, не по имени/сигнатуре. - [x] `ruff check` (project select `E,F,I,B,UP,N,RUF`) — чисто на всех изменённых файлах. - [x] Regression check: `browser/test_server_smoke.py`/`test_server.py` имеют 4 pre-existing failures (устаревшие моки `_ensure_browser` без `proxy_override` kwarg) — воспроизведены на `git stash` (код ДО этого PR), не вызваны этим PR. ### Falsification (числа обоих прогонов) `curl_proxy_url`/`_pool_proxy` prod-refusal тесты (`test_flag_on_empty_pool_prod_refuses_no_http_request`, `test_fetch_pool_empty_prod_refuses_no_http_post` и т.д.): заглушка `_boom()`/`client.post` кидает `AssertionError` на любом вызове внутри `with`-блока / на POST. С фиксом — `NoProxyAvailableError` поднимается ДО `yield`, `_boom()`/`client.post` не достигается → `pytest.raises(NoProxyAvailableError)` матчит. - **После фикса**: `pytest tests/test_proxy_pool_curl_paths.py tests/test_kit_browser_fetcher_proxy_pool.py -q` → **51 passed**. - **До фикса** (подмена `curl_proxy_url`/`_pool_proxy` на pre-fix реализацию, всегда `yield env_fallback_url` при пустом пуле): новые prod-тесты падают на несовпадении типа исключения (`AssertionError` вместо `NoProxyAvailableError`) — подтверждено построчным разбором логики: raise стоит ПОСЛЕ проверки `use_pool and _is_prod(config)`, при revert она снята → `_boom()`/`client.post` выполняются → тест ловит несовпадение типа исключения. ## Файлы - `tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py` (новый) — `NoProxyAvailableError` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py` - `tradein-mvp/backend/app/services/scraper_adapters.py` - `tradein-mvp/browser/server.py` - Тесты: `tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py`, `test_kit_browser_fetcher_proxy_pool.py`, `test_kit_serp_proxy_pool.py` (fixup: recording-стаб принял новый kwarg), `test_scraper_adapters_contracts.py`, `test_scraper_kit_providers_base.py`, `tradein-mvp/browser/test_server_no_proxy_refusal.py` (новый) Refs #2616
lekss361 added 1 commit 2026-08-02 13:59:26 +00:00
fix(tradein/scraper): не ходить через мёртвый запасной прокси — это не бан, а наша конфигурация
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / 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 / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
2fc4b1b0fc
Light1YT added 1 commit 2026-08-04 18:23:46 +00:00
fix(tradein/scraper): дожать guard #2616 до реальных прод-путей (ревью PR)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 2m40s
d4e4dd7124
Ревью нашло: guard NoProxyAvailableError был мёртв ровно там, где нужен —
(1) avito-пути pipeline.py конструируют BrowserFetcher напрямую, минуя
build_browser_fetcher(), и не прокидывали environment → дефолт dev, отказ
никогда не срабатывал; добавлен environment=getattr(config, ...) во все 3
construction-site + source-level тест-гард на новые сайты;
(2) browser-сайдкар: ENVIRONMENT не задан в docker-compose.prod.yml → IS_PROD
guard server.py читал dev; добавлен ENVIRONMENT: production в environment-блок.
bot-backend merged commit 7d13e93792 into main 2026-08-04 18:27:50 +00:00
bot-backend deleted branch fix/tradein-no-dead-env-proxy-fallback 2026-08-04 18:27:50 +00:00
Sign in to join this conversation.
No reviewers
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#2634
No description provided.