Автологин в Циан идёт через узел пула, а не через мёртвый env-узел сайдкара (#3410)
/login сайдкара не принимал proxy из тела (_no_live_proxy(provider, None), _ensure_browser без override), BrowserFetcher.login его не клал, а ручка cian auto-login сознательно не брала провайдер. Итог: логин всегда шёл с SCRAPER_PROXY_URL сайдкара — на проде это выключенный узел 9 (InvalidIP), то есть ручка восстановления cian-сессии не работала вовсе. - сайдкар: /login берёт proxy из тела, как /fetch (guard и _ensure_browser с override; crash-relaunch в _do_login сохраняет _launched_proxy); - scraper-kit: login() кладёт в тело узел текущей аренды, как _post_fetch; - backend: ручка передаёт _kit_proxy_provider(), как debug-карточка DomClick; пустой пул на проде — 503 «нет свободного узла для cian», а не 502. Тесты #3197 про «логин без пула» переписаны на обратные утверждения. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6c5f327acd
commit
4549311720
7 changed files with 164 additions and 59 deletions
|
|
@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
|
|||
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
|
||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -516,16 +517,13 @@ async def cian_auto_login(
|
|||
)
|
||||
|
||||
try:
|
||||
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
|
||||
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
|
||||
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
|
||||
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
|
||||
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
|
||||
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
|
||||
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
|
||||
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
|
||||
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
|
||||
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
|
||||
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
|
||||
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
|
||||
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
|
||||
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
|
||||
async with build_browser_fetcher(
|
||||
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
|
||||
) as fetcher:
|
||||
raw_cookies = await fetcher.login(
|
||||
url=settings.cian_login_url,
|
||||
email=email,
|
||||
|
|
@ -537,6 +535,13 @@ async def cian_auto_login(
|
|||
pre_click_selectors=settings.cian_login_pre_click_selectors,
|
||||
wait_ms=settings.cian_login_wait_ms,
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
|
||||
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("cian auto-login failed: %s", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ.
|
||||
"""#3197 (хвост) + #3410 — две служебные ручки admin.py подключают пул прокси.
|
||||
|
||||
`POST /admin/scrape/domclick/debug/detail-fetch` — **настоящий фикс**. Он ходит через
|
||||
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` — единственные методы, которые кладут
|
||||
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
|
||||
`POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который
|
||||
кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает
|
||||
(`_resolve_proxy_override` → `_ensure_browser(provider, proxy_override=...)`). Прямая
|
||||
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
|
||||
`proxy_provider`/`use_pool`/`environment` — без них сайдкар брал свой env-узел
|
||||
|
|
@ -10,17 +9,11 @@
|
|||
прод-отказ «пул пуст → не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
|
||||
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
|
||||
|
||||
`POST /admin/scrape/cian/auto-login` — **намеренно без пула**, и это проверяется обратными
|
||||
по значению утверждениями. Логин идёт не через `fetch`, а через `login` → `_post_login`,
|
||||
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler`
|
||||
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и
|
||||
`_ensure_browser(provider)` без override — то есть **сайдкар на `/login` proxy-override не
|
||||
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
|
||||
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
|
||||
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST —
|
||||
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
|
||||
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
|
||||
ради endpoint/environment из одного места); пул для логина — отдельная задача сайдкара.
|
||||
`POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login`
|
||||
сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же
|
||||
мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт
|
||||
туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде — честный 503 «нет
|
||||
узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел.
|
||||
|
||||
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
|
||||
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
|
||||
|
|
@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher):
|
|||
"""
|
||||
|
||||
captured: ClassVar[list[dict[str, Any]]] = []
|
||||
login_proxy: ClassVar[list[str | None]] = []
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
_RealLeaseFetcher.captured.append(kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def login(self, **_kwargs: Any) -> dict[str, str]:
|
||||
# Узел, который настоящий login() положил бы в тело /login (см. его код).
|
||||
_RealLeaseFetcher.login_proxy.append(self._current_proxy()[0])
|
||||
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
|
||||
|
||||
|
||||
class _SpyProvider:
|
||||
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None.
|
||||
"""Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст).
|
||||
|
||||
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
|
||||
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
|
||||
|
|
@ -99,10 +95,11 @@ class _SpyProvider:
|
|||
|
||||
acquired: ClassVar[list[str]] = []
|
||||
released: ClassVar[list[int]] = []
|
||||
lease: ClassVar[ProxyLease | None] = None
|
||||
|
||||
def acquire(self, provider: str) -> ProxyLease | None:
|
||||
_SpyProvider.acquired.append(provider)
|
||||
return None
|
||||
return _SpyProvider.lease
|
||||
|
||||
def release(self, lease: ProxyLease) -> None:
|
||||
_SpyProvider.released.append(lease.id)
|
||||
|
|
@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
|
||||
_CapturingFetcher.captured = []
|
||||
_RealLeaseFetcher.captured = []
|
||||
_RealLeaseFetcher.login_proxy = []
|
||||
_SpyProvider.acquired = []
|
||||
_SpyProvider.released = []
|
||||
_SpyProvider.lease = None
|
||||
|
||||
|
||||
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
|
||||
|
|
@ -149,13 +148,11 @@ def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None:
|
|||
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||
@pytest.mark.usefixtures("_pool_on")
|
||||
async def test_cian_auto_login_does_not_lease_from_pool(
|
||||
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
||||
) -> None:
|
||||
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг)."""
|
||||
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
|
||||
async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""#3410: прод + узел в пуле → логин идёт через него, аренда отпускается."""
|
||||
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
||||
_SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")
|
||||
|
||||
with ExitStack() as stack:
|
||||
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||
|
|
@ -165,8 +162,12 @@ async def test_cian_auto_login_does_not_lease_from_pool(
|
|||
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
|
||||
kwargs = _RealLeaseFetcher.captured[0]
|
||||
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
||||
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен"
|
||||
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)"
|
||||
assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла"
|
||||
assert kwargs.get("use_pool") is True
|
||||
assert kwargs.get("environment") == "production"
|
||||
assert _SpyProvider.acquired == ["cian"]
|
||||
assert _RealLeaseFetcher.login_proxy == ["http://u:p@pool:8080"]
|
||||
assert _SpyProvider.released == [13], "аренда логина не должна течь"
|
||||
# endpoint из фабрики не должен потеряться (#2322: без него TypeError).
|
||||
from app.core.config import settings
|
||||
|
||||
|
|
@ -174,28 +175,40 @@ async def test_cian_auto_login_does_not_lease_from_pool(
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("_pool_on")
|
||||
async def test_cian_auto_login_survives_empty_pool_in_production(
|
||||
async def test_cian_auto_login_empty_pool_in_production_is_honest_503(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502).
|
||||
"""Пустой пул на проде → 503 «нет узла для cian», логин не запускается.
|
||||
|
||||
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при
|
||||
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`,
|
||||
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` — то
|
||||
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
|
||||
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
|
||||
Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на
|
||||
env-узел сайдкара (#2616: на проде он мёртв).
|
||||
"""
|
||||
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
||||
|
||||
with ExitStack() as stack:
|
||||
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||
try:
|
||||
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||
except HTTPException as exc:
|
||||
# pytest.fail, а не re-raise: красное должно называть статус и detail.
|
||||
pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||
|
||||
assert result["ok"] is True and result["userId"] == 7
|
||||
assert (exc_info.value.status_code, "пул" in str(exc_info.value.detail)) == (503, True)
|
||||
assert _SpyProvider.acquired == ["cian"]
|
||||
assert _RealLeaseFetcher.login_proxy == [], "логин без узла запускаться не должен"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_pool_on")
|
||||
async def test_cian_auto_login_dev_without_browser_pool_uses_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""dev + browser-пул выключен → аренды нет, логин без proxy в теле (env сайдкара)."""
|
||||
_cian_login_settings(monkeypatch, use_pool=False, env="dev")
|
||||
|
||||
with ExitStack() as stack:
|
||||
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert _SpyProvider.acquired == []
|
||||
assert _RealLeaseFetcher.login_proxy == [None]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||
|
|
|
|||
|
|
@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None:
|
|||
assert provider.released == []
|
||||
assert bf._lease is not None
|
||||
assert bf._lease.id == 5
|
||||
|
||||
|
||||
# ── #3410: /login несёт узел аренды, как /fetch ────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_pool", [True, False])
|
||||
async def test_login_body_carries_lease_proxy(use_pool: bool) -> None:
|
||||
"""Аренда есть → в теле /login тот же proxy/proxy_kind, что ушёл бы в /fetch; нет → без."""
|
||||
provider = _FakeProxyProvider(ProxyLease(id=13, url="http://u:p@pool:8080", kind="http"))
|
||||
client = _mock_client({"cookies": [{"name": "DMIR_AUTH", "value": "v"}]})
|
||||
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=use_pool)
|
||||
|
||||
cookies = await bf.login(
|
||||
url="https://www.cian.ru/authenticate/",
|
||||
email="a@b.c",
|
||||
password="pw",
|
||||
email_selector="#e",
|
||||
password_selector="#p",
|
||||
submit_selector="#s",
|
||||
success_cookie="DMIR_AUTH",
|
||||
)
|
||||
await bf.__aexit__(None, None, None)
|
||||
|
||||
assert cookies == {"DMIR_AUTH": "v"}
|
||||
url_called = client.post.call_args.args[0]
|
||||
body = client.post.call_args.kwargs["json"]
|
||||
assert url_called == "http://browser:3000/login"
|
||||
if use_pool:
|
||||
assert (body.get("proxy"), body.get("proxy_kind")) == ("http://u:p@pool:8080", "http")
|
||||
assert provider.released == [13]
|
||||
else:
|
||||
assert "proxy" not in body and "proxy_kind" not in body
|
||||
assert provider.acquired == []
|
||||
|
|
|
|||
|
|
@ -2822,12 +2822,16 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
|
||||
|
||||
provider = _resolve_provider(body, body["url"])
|
||||
# #3410: прокси из пула в теле — как у /fetch. Без него логин шёл только с env-узла
|
||||
# сайдкара (на проде — выключенный узел, InvalidIP), и ручка восстановления сессии
|
||||
# не работала при живом пуле. relaunch при смене прокси — внутри _ensure_browser.
|
||||
proxy_override = _resolve_proxy_override(body, provider)
|
||||
|
||||
# #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) →
|
||||
# #2616 шаг 1: см. fetch_handler — прод + нет НИ override, НИ env-прокси →
|
||||
# отказ, не direct-IP.
|
||||
if IS_PROD and _no_live_proxy(provider, None):
|
||||
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — "
|
||||
"tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — "
|
||||
"не подключаемся напрямую с IP сервера (#2616)",
|
||||
provider,
|
||||
)
|
||||
|
|
@ -2839,7 +2843,7 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
async with lock:
|
||||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||
# Lazy-попытка, иначе 503 — без прокси не логинимся.
|
||||
if not await _ensure_browser(provider):
|
||||
if not await _ensure_browser(provider, proxy_override=proxy_override):
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
|
||||
provider,
|
||||
|
|
|
|||
|
|
@ -267,6 +267,52 @@ def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.Mo
|
|||
assert calls == [("avito", "http://pool:8080")]
|
||||
|
||||
|
||||
_LOGIN_BODY: dict[str, Any] = {
|
||||
"url": "https://www.cian.ru/login",
|
||||
"email": "a@b.ru",
|
||||
"password": "x",
|
||||
"email_selector": "#e",
|
||||
"password_selector": "#p",
|
||||
"submit_selector": "#s",
|
||||
"success_cookie": "sid",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra", "expected_override"),
|
||||
[({"proxy": "http://pool:8080", "proxy_kind": "http"}, "http://pool:8080"), ({}, None)],
|
||||
)
|
||||
def test_login_handler_prod_uses_body_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch, extra: dict[str, Any], expected_override: str | None
|
||||
) -> None:
|
||||
"""#3410: /login берёт прокси из тела, как /fetch; без него — прежний env-путь.
|
||||
|
||||
С прокси в теле и БЕЗ env-прокси прод-guard не отказывает (узел пула есть), а
|
||||
браузер поднимается ровно с этим узлом. Без прокси в теле, но с env — override None.
|
||||
"""
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
if not extra:
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
calls.append((provider, proxy_override))
|
||||
return True
|
||||
|
||||
async def _fake_do_login(provider: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [{"name": "sid", "value": "1"}]
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
monkeypatch.setattr(server, "_do_login", _fake_do_login)
|
||||
|
||||
response = asyncio.run(
|
||||
server.login_handler(_make_request("/login", {**_LOGIN_BODY, **extra}))
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert calls == [("cian", expected_override)]
|
||||
|
||||
|
||||
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -656,6 +656,13 @@ class BrowserFetcher:
|
|||
}
|
||||
if wait_ms is not None:
|
||||
body["wait_ms"] = wait_ms
|
||||
# #3410: узел аренды — в тело /login, как в _post_fetch. Без него сайдкар логинился
|
||||
# с env-узла при любой аренде.
|
||||
proxy_url, proxy_kind = self._current_proxy()
|
||||
if proxy_url:
|
||||
body["proxy"] = proxy_url
|
||||
if proxy_kind:
|
||||
body["proxy_kind"] = proxy_kind
|
||||
|
||||
try:
|
||||
return await self._post_login(body)
|
||||
|
|
|
|||
|
|
@ -212,13 +212,10 @@ def build_browser_fetcher(
|
|||
Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были
|
||||
последними прямыми конструкциями мимо фабрики — переведены сюда же (#3197 хвост);
|
||||
прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают
|
||||
своими kwargs. Живой `proxy_provider=None` остался ровно один — cian-login: сайдкар
|
||||
на `/login` proxy-override не берёт (`browser/server.py::login_handler` →
|
||||
`_no_live_proxy(provider, None)`, а `_post_login` не кладёт `payload["proxy"]`), так
|
||||
что аренда там была бы холостой, а на проде при пустом пуле роняла бы ручку
|
||||
восстановления в 502. `use_pool` при `proxy_provider is None` игнорируется
|
||||
`BrowserFetcher` (`_acquire_lease`: `use_pool AND provider is not None`) — ни аренды,
|
||||
ни прод-отказа, поведение как до фабрики.
|
||||
своими kwargs. cian-login тоже с провайдером (#3410): сайдкар на `/login` берёт proxy
|
||||
из тела, `BrowserFetcher.login` кладёт туда узел аренды. `use_pool` при
|
||||
`proxy_provider is None` игнорируется `BrowserFetcher` (`_acquire_lease`:
|
||||
`use_pool AND provider is not None`) — ни аренды, ни прод-отказа.
|
||||
|
||||
`fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher`
|
||||
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue