Merge pull request 'fix(tradein/avito): повтор после блока терял прокси и уходил с адреса машины' (#3143) from fix/3034-detail-retry-loses-proxy into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / test (push) Successful in 3m58s
Deploy Trade-In / deploy (push) Successful in 1m17s
Deploy Trade-In / deploy-status (push) Successful in 1s

This commit is contained in:
lekss361 2026-08-27 12:40:11 +00:00
commit 1cefd806b3
2 changed files with 103 additions and 2 deletions

View file

@ -0,0 +1,88 @@
"""Повтор после блока обязан сохранять прокси, ради которого он и затеян.
БАГ. В `fetch_detail` обе ветки повтора (403/firewall и 429) пересоздавали
эфемерную сессию вызовом `_build_detail_session()` БЕЗ `config`. А прокси
kit-версия читает только из `config.scraper_proxy_url`, поэтому такая сессия
уходила напрямую с адреса самой машины.
Замысел ветки прямо обратный, он записан в её же комментарии: «эфемерная
свежая сессия (новый CONNECT-туннель = свежий exit-IP)». Ветка вообще
исполняется только при `backconnect=True`, а он вычисляется как «задан
`config.scraper_proxy_url`» то есть в момент вызова достоверно известно, что
прокси есть, и он терялся.
ПОЧЕМУ ЭТО БЫЛО НЕ ВИДНО. Пока адрес самой машины не был заблокирован, прямой
повтор часто срабатывал, и подмена канала выглядела как успех. Замер 27.08 из
прод-контейнера, один и тот же URL Авито:
через прокси 200, 3.3 МБ страницы
напрямую 429, «доступ ограничен», firewall
С этого момента каждый повтор после блока стал обречён. Обогащение
`avito_detail_backfill` по суткам: 21-25.08 178/129/147/138/111, 26.08 23,
27.08 0 при 25 блоках.
Тот же класс ошибки уже чинили в #2330 для `build_warmed_session`; здесь он
оставался в пути повтора.
"""
from __future__ import annotations
import inspect
import re
from pathlib import Path
from types import SimpleNamespace
from scraper_kit.providers.avito import detail as kit_detail
SOURCE = Path(inspect.getfile(kit_detail)).read_text(encoding="utf-8")
def _retry_rebuild_calls() -> list[str]:
"""Все места, где пересоздаётся эфемерная `retry_session`."""
return re.findall(r"retry_session = _build_detail_session\(([^)]*)\)", SOURCE)
def test_both_retry_branches_exist() -> None:
"""Страховка от того, что тест начнёт проверять пустоту.
Веток две 403/firewall и 429; если рефакторинг их переименует, тест ниже
молча перестанет что-либо проверять, поэтому фиксируем количество.
"""
assert len(_retry_rebuild_calls()) == 2, (
f"ожидались две ветки повтора, найдено: {_retry_rebuild_calls()}"
)
def test_retry_session_is_rebuilt_with_config() -> None:
"""Ни одна ветка повтора не смеет строить сессию без `config`.
Пустой аргумент = `proxy_url=None` = выход напрямую с адреса машины, тогда
как вся ветка существует ради свежего exit-IP ЧЕРЕЗ прокси.
"""
for arg in _retry_rebuild_calls():
assert arg.strip() == "config", (
"повтор строит сессию как _build_detail_session("
f"{arg!r}) — прокси теряется, запрос уйдёт напрямую"
)
def test_session_without_config_really_has_no_proxy() -> None:
"""Доказываем цену пропуска, а не верим на слово.
Ровно то же построение, что делала сломанная ветка: без config прокси в
сессии нет, с config есть.
"""
proxy = "http://user:pass@proxy.example:8080"
with_cfg = kit_detail._build_detail_session(SimpleNamespace(scraper_proxy_url=proxy))
without_cfg = kit_detail._build_detail_session()
try:
assert proxy in str(getattr(with_cfg, "proxies", "")), "прокси не доехал из config"
assert not getattr(without_cfg, "proxies", None), (
"сессия без config неожиданно с прокси — тест ниже потерял бы смысл"
)
finally:
for s in (with_cfg, without_cfg):
close = getattr(s, "close", None)
if close is not None and not inspect.iscoroutinefunction(close):
close()

View file

@ -597,7 +597,9 @@ async def fetch_detail(
await retry_session.close()
except Exception:
pass
retry_session = _build_detail_session()
# config ОБЯЗАТЕЛЕН: без него прокси не подставляется
# вовсе — разбор того, чем это кончается, у 429-ветки ниже.
retry_session = _build_detail_session(config)
attempt_session = retry_session
logger.info(
"avito detail 403/firewall/soft-block %s — backconnect reconnect "
@ -624,7 +626,18 @@ async def fetch_detail(
await retry_session.close()
except Exception:
pass
retry_session = _build_detail_session()
# config ОБЯЗАТЕЛЕН. Без него `_build_detail_session` берёт
# proxy_url=None, и «эфемерная сессия ради свежего exit-IP»
# уходит НАПРЯМУЮ с адреса самой машины — то есть делает
# ровно обратное замыслу. Ветка исполняется только когда
# backconnect=True, а он и означает «прокси задан»: мы
# достоверно знаем, что прокси есть, и теряли его здесь.
#
# Замер 27.08 из прод-контейнера, один и тот же URL:
# через прокси — 200, 3.3 МБ страницы
# напрямую — 429, «доступ ограничен», firewall
# То есть каждый повтор после блока был обречён.
retry_session = _build_detail_session(config)
attempt_session = retry_session
logger.info(
"avito detail HTTP 429 short-retry exhausted %s"