fix(#3386): curl_proxy_url — health=False и на отмене (BaseException); admin IMV через пул; тест без create=True
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m57s

This commit is contained in:
bot-backend 2026-09-06 10:57:08 +05:00
parent 6f97995140
commit 33cad4f82e
4 changed files with 45 additions and 6 deletions

View file

@ -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

View file

@ -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 вокруг него.

View file

@ -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)

View file

@ -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