gendesign/backend/tests/services/scrapers/test_2464_download_binary_retry.py
bot-backend f72a08eb80
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m0s
CI / backend-tests (pull_request) Successful in 17m31s
fix(ptica): download_binary переживает транзиентный ответ, как и get_json (#2464)
Обе функции ходят через один браузерный контекст, под один и тот же WAF.
`get_json` держит до пяти попыток с экспоненциальным backoff на 429/5xx/0,
а `download_binary` не имел ретраев вовсе: один 429 ронял загрузку
картинки насовсем, и вызывающий (`download_plan_image`, `download_photos`)
писал в лог «не удалось» — неотличимо от «файла нет».

Правильный образец лежал в этом же классе, двадцатью строками выше.

Непереходные коды (403, 404) поднимаются сразу, без ожидания: повтор их
не изменит, а лишний стук под WAF вредит. Разбор статуса вынесен ЗА
семафор — sleep не должен держать слот.

Двусторонне: против origin/main транзиентные тесты красные с конкретным
значением («вместо байтов получили RuntimeError('binary http 429…');
попыток=1»), ни одного ImportError/TypeError.

Контроли зелёные с обеих сторон: 403 и 404 не повторяются, исчерпание
попыток даёт честную ошибку, а не пустые байты, успех с первой попытки не
порождает лишних запросов.

Отдельный контроль на паузы: без него «ретраит» и «долбит без пауз»
неотличимы в тесте, а под WAF разница между ними решающая — проверяется,
что задержки растут как 1, 2 секунды.

pytest backend/tests/services/scrapers/ — 340 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:57:14 +05:00

156 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""download_binary переживает транзиентный ответ, как и get_json (#2464).
Обе функции ходят через один и тот же браузерный контекст, под один и тот же WAF.
`get_json` держит до пяти попыток с экспоненциальным backoff на 429/5xx/0, а
`download_binary` не имел ретраев вовсе: один 429 ронял загрузку картинки
насовсем, и вызывающий (`download_plan_image`, `download_photos`) писал в лог
«не удалось» — неотличимо от «файла нет».
Правильный образец лежал в этом же классе, двадцатью строками выше.
Тесты гоняют настоящий `download_binary` на двойнике Playwright-контекста;
`asyncio.sleep` подменён, чтобы backoff не тратил время теста, — но факт ожидания
проверяется отдельно, иначе «ретраит» и «долбит без пауз» неотличимы.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import patch
import pytest
from app.services.scrapers.stealth import BrowserSession
class _Resp:
def __init__(self, status: int, body: bytes = b"", text: str = "") -> None:
self.status = status
self._body = body
self._text = text
async def body(self) -> bytes:
return self._body
async def text(self) -> str:
return self._text
class _Request:
"""Отдаёт заранее заданную очередь ответов и считает вызовы."""
def __init__(self, ответы: list[Any]) -> None:
self._ответы = list(ответы)
self.calls = 0
async def get(self, url: str, headers: dict | None = None) -> _Resp:
self.calls += 1
item = self._ответы.pop(0) if self._ответы else _Resp(200, b"ok")
if isinstance(item, Exception):
raise item
return item
class _Context:
def __init__(self, ответы: list[Any]) -> None:
self.request = _Request(ответы)
def _session(ответы: list[Any]) -> tuple[BrowserSession, _Context]:
s = BrowserSession.__new__(BrowserSession)
ctx = _Context(ответы)
s._context = ctx # type: ignore[attr-defined]
s._sem = asyncio.Semaphore(1) # type: ignore[attr-defined]
s._request_count = 0 # type: ignore[attr-defined]
s.auth = None # type: ignore[attr-defined]
return s, ctx
def _run(ответы: list[Any]) -> tuple[Any, _Context, list[float]]:
"""Прогнать download_binary, вернуть (результат|исключение, контекст, паузы)."""
паузы: list[float] = []
async def _fake_sleep(d: float) -> None:
паузы.append(d)
s, ctx = _session(ответы)
with (
patch("app.services.scrapers.stealth.asyncio.sleep", _fake_sleep),
patch("app.services.scrapers.stealth.jitter_sleep", lambda *a, **k: _fake_sleep(0)),
):
try:
res: Any = asyncio.run(s.download_binary("https://x/y.png"))
except Exception as exc:
res = exc
return res, ctx, паузы
def test_transient_429_is_retried_not_fatal() -> None:
"""Головной: 429 с последующим успехом обязан дать байты, а не исключение.
На origin/main первая же попытка поднимает RuntimeError — картинка теряется.
"""
res, ctx, _ = _run([_Resp(429, text="rate limited"), _Resp(200, b"PNGDATA")])
assert res == b"PNGDATA", f"вместо байтов получили {res!r}; попыток={ctx.request.calls}"
assert ctx.request.calls == 2, f"повтор не выполнен: попыток={ctx.request.calls}"
def test_transient_5xx_is_retried() -> None:
"""503 — тоже транзиент, как и в get_json."""
res, ctx, _ = _run([_Resp(503, text="bad gw"), _Resp(502, text="bad gw"), _Resp(200, b"OK")])
assert res == b"OK", f"{res!r}"
assert ctx.request.calls == 3
def test_backoff_actually_waits() -> None:
"""Контроль от «ретраит, но долбит без пауз»: паузы растут экспоненциально.
Без этой проверки цикл без sleep выглядел бы в тестах так же, как с ним, —
а под WAF разница между ними решающая.
"""
_, _, паузы = _run([_Resp(429), _Resp(429), _Resp(200, b"OK")])
задержки = [p for p in паузы if p > 0]
assert задержки[:2] == [1, 2], f"backoff не экспоненциальный: {задержки}"
def test_permanent_403_is_not_retried() -> None:
"""Контроль от переусердствования: 403 поднимается сразу.
Повтор его не изменит, а лишний стук под WAF вредит.
"""
res, ctx, _ = _run([_Resp(403, text="forbidden"), _Resp(200, b"OK")])
assert isinstance(res, RuntimeError), f"403 не поднял ошибку: {res!r}"
assert "403" in str(res)
assert ctx.request.calls == 1, f"403 был повторён: попыток={ctx.request.calls}"
def test_404_is_not_retried() -> None:
"""Контроль: отсутствующий файл — не транзиент."""
res, ctx, _ = _run([_Resp(404, text="no such file")])
assert isinstance(res, RuntimeError) and "404" in str(res)
assert ctx.request.calls == 1
def test_exhausted_retries_raise_with_the_last_error() -> None:
"""Контроль честности отказа: после пяти попыток — ошибка, а не пустые байты."""
res, ctx, _ = _run([_Resp(429) for _ in range(5)])
assert isinstance(res, RuntimeError), f"{res!r}"
assert "exhausted" in str(res), str(res)
assert ctx.request.calls == 5, f"попыток={ctx.request.calls}"
def test_network_exception_is_retried_too() -> None:
"""Обрыв соединения — тоже транзиент (зеркалит ветку except в get_json)."""
res, ctx, _ = _run([ConnectionError("boom"), _Resp(200, b"OK")])
assert res == b"OK", f"{res!r}"
assert ctx.request.calls == 2
@pytest.mark.parametrize("status", [200])
def test_success_on_first_try_makes_one_request(status: int) -> None:
"""Контроль: успех с первой попытки не порождает лишних запросов."""
res, ctx, паузы = _run([_Resp(status, b"OK")])
assert res == b"OK"
assert ctx.request.calls == 1
assert [p for p in паузы if p > 0] == [], f"лишние паузы: {паузы}"