diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 033aa394..dfc68eac 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1106,6 +1106,10 @@ async def scrape_avito_imv( has_balcony=has_balcony, has_loggia=has_loggia, config=RealScraperConfig(), + # #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND + # provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613). + # _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел. + proxy_provider=_kit_proxy_provider(), ) except IMVAddressNotFoundError as e: # Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning diff --git a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py index f844a541..d91726f3 100644 --- a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py @@ -158,9 +158,10 @@ def test_empty_pool_in_production_degrades_without_imv( async def _run() -> None: nonlocal result with ( - # create=True: на main символа в estimator нет, и без него тест краснел бы - # AttributeError'ом («возможности нет»), а не неверным ЗНАЧЕНИЕМ. - patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider, create=True), + # Без create=True: `RealProxyProvider` импортирован в estimator (estimator.py:83), + # патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта + # оставило бы тест зелёным против фантома. + patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider), patch("curl_cffi.requests.AsyncSession", _no_http), ): result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4") @@ -210,7 +211,7 @@ def _run_with_pool( async def _run() -> None: nonlocal result with ( - patch.object(estimator, "RealProxyProvider", lambda: provider, create=True), + patch.object(estimator, "RealProxyProvider", lambda: provider), patch.object(estimator, "save_imv_evaluation", return_value=1), patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session), # Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него. diff --git a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py index dff4a4ad..fe94b232 100644 --- a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py +++ b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py @@ -9,11 +9,14 @@ class-based провайдера (YandexValuationScraper): - флаг on + lease → fetch через lease.url, mark_health вызван, release в finally (в prod и dev одинаково — пул выдал лизу, отказа быть не должно); - исключение внутри блока → mark_health(ok=False) + release всё равно (lease не течёт); + - отмена/прерывание (CancelledError/KeyboardInterrupt/SystemExit — BaseException, не + Exception) внутри блока → тот же ok=False, исключение пролетает наружу; - acquire кинул → fallback env (dev) / NoProxyAvailableError (prod). """ from __future__ import annotations +import asyncio import os from dataclasses import dataclass from typing import Any @@ -159,6 +162,30 @@ def test_plain_exception_does_not_call_mark_banned() -> None: assert spy.release_calls == [7] +@pytest.mark.parametrize( + "exc_type", [asyncio.CancelledError, KeyboardInterrupt, SystemExit], ids=lambda t: t.__name__ +) +def test_base_exception_marks_fail_and_still_releases(exc_type: type[BaseException]) -> None: + """Отмена/прерывание внутри блока — тоже ok=False, а не «узел здоров». + + `asyncio.CancelledError` наследует BaseException, а не Exception: при таймауте + (`asyncio.wait_for` в `_with_budget` эстиматора) отмена приходит внутрь await'а + ВНУТРИ блока. С `except Exception` она пролетала мимо, `ok` оставался True и + в finally узел, из-за которого запрос завис до отмены, записывался ЗДОРОВЫМ. + KeyboardInterrupt/SystemExit — тот же путь: health честный, но исключение + обязано пролететь наружу (`raise` в блоке сохранён, не проглатываем). + """ + cfg = _FakeConfig(use_proxy_pool_curl=True) + spy = _SpyProvider(_LEASE) + with pytest.raises(exc_type): + with curl_proxy_url(cfg, spy, "avito", env_fallback_url=None) as url: + assert url == _LEASE.url + raise exc_type() + assert spy.mark_health_calls == [(7, False)] + assert spy.release_calls == [7] # ровно один раз, lease не течёт + assert spy.mark_banned_calls == [] # отмена ≠ бан площадки + + def test_acquire_raises_falls_back_to_env() -> None: cfg = _FakeConfig(use_proxy_pool_curl=True) spy = _SpyProvider(_LEASE, acquire_raises=True) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py index baba2926..19e21a9d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py @@ -5,7 +5,8 @@ (env_fallback_url) — curl-пути ходят ровно как сейчас, прод не меняется (легитимный dev/no-op путь — см. providers/cian/detail.py:118). - Флаг on + пул выдал lease → yield lease.url; на выходе mark_health(ok) + release(lease). - ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка). + ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка/ + отмена — ловим BaseException, иначе таймаут-cancel записывал бы зависший узел здоровым). - Флаг on + пул пуст/ошибка acquire + окружение НЕ "production" (dev/test) → fallback на env_fallback_url, НЕ падаем (легитимно для локальной разработки/тестов). - Флаг on + пул пуст/ошибка acquire + окружение "production" (#2616 шаг 1) → НЕ идём на @@ -112,7 +113,13 @@ def curl_proxy_url( banned = False try: yield lease.url - except Exception as exc: + except BaseException as exc: + # BaseException, а не Exception: `asyncio.CancelledError` (таймаут `wait_for` — + # напр. `_with_budget` эстиматора — отменяет await ВНУТРИ блока) наследует + # BaseException. С `except Exception` отмена пролетала мимо, ok оставался True, + # и в finally узел, из-за которого запрос завис до отмены, писался ЗДОРОВЫМ. + # `raise` сохранён — KeyboardInterrupt/SystemExit пролетают наружу как раньше, + # меняется только честность health-сигнала. ok = False banned = isinstance(exc, ProxyBanError) raise