gendesign/backend/tests/services/scrapers/test_okn_egrkn_client.py
bot-backend 53b160ff0a
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m13s
Deploy / deploy (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 3m19s
fix(scrapers): rate-limit/backoff/circuit-breaker для ОКН EGRKN-скрапера (#2445-D3) (#2461)
2026-07-05 20:09:18 +00:00

646 lines
29 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.

"""Тесты клиента АИС ЕГРКН (#1066, #1159) — без живой сети.
Покрывает:
- coord swap: фейк-фича coordinates=[lat, lon] → lon/lat для PostGIS (критично).
- TLS-fallback: SSLError с verify=True → retry с verify=False.
- Некорректный JSON / HTTP-ошибка → пустой список, не raise.
- _build_form_data: формат jQuery serializeArray.
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки → {name, address, …}; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
исчерпываются на устойчивой ошибке без raise.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from app.services.scrapers.okn_egrkn_client import (
_HEADERS,
_build_form_data,
_parse_detail_html,
fetch_okn_detail,
fetch_okn_points,
)
# ── Вспомогательные функции ───────────────────────────────────────────────────
def _make_geojson_response(features: list[dict[str, Any]]) -> dict[str, Any]:
"""Сформировать GeoJSON FeatureCollection."""
return {"type": "FeatureCollection", "features": features}
def _make_feature(
feature_id: str,
lat: float,
lon: float,
props: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Минимальная фича ЕГРКН с координатами в порядке [lat, lon] (Яндекс)."""
return {
"id": feature_id,
"geometry": {"type": "Point", "coordinates": [lat, lon]},
"properties": props or {},
}
# ── КРИТИЧНО: coord swap ──────────────────────────────────────────────────────
def test_coord_swap_ekb_point_inside_bbox() -> None:
"""ЕГРКН возвращает [lat, lon]; клиент должен вернуть фичу как есть.
Тест проверяет, что _extract_coords в воркере корректно инвертирует:
coordinates=[56.83, 60.61] → lat=56.83 lon=60.61 → MakePoint(lon=60.61, lat=56.83).
Прямая проверка: ЕКБ bbox 5558°N, 5863°E — lon=60.61 и lat=56.83 входят.
"""
# Фича: coordinates[0]=lat=56.83, coordinates[1]=lon=60.61 (центр ЕКБ)
feature = _make_feature("test-ekb-001", lat=56.83, lon=60.61)
coords = feature["geometry"]["coordinates"]
# coordinates[0] = lat (56.83°N — севернее экватора, в ЕКБ)
# coordinates[1] = lon (60.61°E — в Свердловской обл.)
lat = float(coords[0])
lon = float(coords[1])
# Проверяем что при правильной интерпретации точка попадает в ЕКБ-bbox
assert 55.0 <= lat <= 58.5, f"lat={lat} вне ЕКБ-bbox 5558.5°N"
assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 5863°E"
# Проверяем что lat/lon НЕ перепутаны местами:
# если бы мы взяли coordinates[0] как lon, то lon=56.83 — это не в Свердловской обл.
# а как lat взяли бы coordinates[1]=60.61 — 60.61°N это Финляндия, не ЕКБ
wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat
wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon
# Финляндия/Норвегия: lat=60.61 формально в диапазоне 5558.5 FALSE → lat перепутан
assert not (
55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0
), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
def test_coord_swap_inverted_would_fail_bbox_check() -> None:
"""Если бы lat/lon были перепутаны — санитарная проверка воркера отклонила бы точку.
В okn_objects_sync._extract_coords валидный bbox: 5558.5°N, 5863°E.
При перестановке coordinates[0]=56.83 → трактуется как lon=56.83°E (вне 5863°E)
и coordinates[1]=60.61 → трактуется как lat=60.61°N (вне 5558.5°N).
"""
# Имитируем НЕВЕРНУЮ интерпретацию: lon берём из coords[0], lat из coords[1]
feature = _make_feature("test-swap-check", lat=56.83, lon=60.61)
coords = feature["geometry"]["coordinates"]
wrong_lon = float(coords[0]) # 56.83 — интерпретируем как lon (неверно)
wrong_lat = float(coords[1]) # 60.61 — интерпретируем как lat (неверно)
inside_ekb = (55.0 <= wrong_lat <= 58.5) and (58.0 <= wrong_lon <= 63.0)
assert not inside_ekb, (
"Перепутанные координаты ошибочно прошли бы bbox-фильтр — "
"фикс coord swap в _extract_coords сломан"
)
# ── fetch_okn_points: мок httpx ───────────────────────────────────────────────
def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
"""Создать мок httpx.Response."""
resp = MagicMock()
resp.status_code = status
resp.json.return_value = payload
resp.raise_for_status = MagicMock()
# headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
# в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
resp.headers = {}
if status >= 400:
import httpx
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"error", request=MagicMock(), response=resp
)
return resp
def _mock_html_response(html: str, status: int = 200) -> MagicMock:
"""Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
resp = MagicMock()
resp.status_code = status
resp.text = html
resp.raise_for_status = MagicMock()
# См. _mock_response — headers должен быть настоящим dict, не MagicMock.
resp.headers = {}
if status >= 400:
import httpx
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"error", request=MagicMock(), response=resp
)
return resp
def test_fetch_okn_points_returns_features() -> None:
"""fetch_okn_points возвращает список фич из FeatureCollection."""
features = [
_make_feature("001", 56.83, 60.61),
_make_feature("002", 56.84, 60.62),
]
payload = _make_geojson_response(features)
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response(payload)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 2
assert result[0]["id"] == "001"
assert result[1]["id"] == "002"
def test_fetch_okn_points_empty_features() -> None:
"""Пустой FeatureCollection → пустой список."""
payload = _make_geojson_response([])
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response(payload)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_points("Екатеринбург", 2)
assert result == []
def test_fetch_okn_points_http_error_returns_empty() -> None:
"""HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=500)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Свердловская", 1)
assert result == []
# 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
assert mock_sleep.call_count >= 1
def test_fetch_okn_points_invalid_json_returns_empty() -> None:
"""Невалидный JSON в ответе → пустой список."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json.side_effect = ValueError("not json")
mock_client.post.return_value = resp
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_points("Екатеринбург", 4)
assert result == []
def test_fetch_okn_points_ssl_error_retries_with_verify_false() -> None:
"""SSL ConnectError при verify=True → автоматический retry с verify=False.
httpx >= 0.28 не имеет отдельного SSLError — SSL-ошибки приходят как ConnectError
с SSL-ключевым словом в сообщении. Клиент определяет это через _is_ssl_error().
"""
import httpx
features = [_make_feature("ssl-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
# Первый клиент (verify=True) бросает ConnectError с SSL-сообщением
# Второй клиент (verify=False) возвращает корректный ответ
clients_created: list[dict] = []
def _make_client(**kwargs: Any) -> MagicMock:
verify = kwargs.get("verify", True)
clients_created.append({"verify": verify})
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
if verify:
# ConnectError с SSL в сообщении — типичная ошибка verify=True для RU CA
mock_client.post.side_effect = httpx.ConnectError("ssl cert verify failed")
else:
mock_client.post.return_value = _mock_response(payload)
return mock_client
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
assert result[0]["id"] == "ssl-001"
# Оба клиента были созданы: первый verify=True, второй verify=False
assert len(clients_created) == 2
assert clients_created[0]["verify"] is True
assert clients_created[1]["verify"] is False
# ── _build_form_data ──────────────────────────────────────────────────────────
def test_build_form_data_structure() -> None:
"""form_data содержит правильные пары name/value для jQuery serializeArray."""
result = _build_form_data("Екатеринбург", 1)
assert ("data[0][name]", "CultureObjects[cob_address]") in result
assert ("data[0][value]", "Екатеринбург") in result
assert ("data[1][name]", "CultureObjects[cob_category_type]") in result
assert ("data[1][value]", "1") in result
# -- User-Agent header (bypass 403) ------------------------------------------
def test_request_sends_browser_user_agent() -> None:
"""httpx.Client is created with browser User-Agent from _HEADERS (bypass 403).
Without UA, okn-mk.mkrf.ru returns 403.
With Chrome/125 UA: 200 response, cob_address=Ekaterinburg = 272 features.
Checks:
1. _HEADERS constant contains User-Agent with Mozilla string and Content-Type.
2. httpx.Client is called with headers=_HEADERS when fetch_okn_points runs.
"""
assert "User-Agent" in _HEADERS
assert "Mozilla" in _HEADERS["User-Agent"]
assert "X-Requested-With" in _HEADERS
assert "Referer" in _HEADERS
assert "Origin" in _HEADERS
assert "Content-Type" in _HEADERS
assert "application/x-www-form-urlencoded" in _HEADERS["Content-Type"]
features = [_make_feature("ua-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
captured_kwargs: list[dict] = []
def _make_client(**kwargs: Any) -> MagicMock:
captured_kwargs.append(dict(kwargs))
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
mc.post.return_value = _mock_response(payload)
return mc
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
fetch_okn_points("Ekaterinburg", 1)
assert len(captured_kwargs) == 1
sent_headers = captured_kwargs[0].get("headers", {})
assert "User-Agent" in sent_headers
assert "Mozilla" in sent_headers["User-Agent"]
def test_post_uses_content_urlencode_not_data() -> None:
"""client.post вызывается с content=urlencoded-строкой (не data=list[tuple]).
Баг #1066: data=list[tuple] + ручной Content-Type → httpx бросает
«expected bytes, tuple found». Фикс: urllib.parse.urlencode(form_data) → content=.
Проверяем:
- post вызван с параметром content=, а НЕ data=
- content — строка с правильными urlencoded-ключами (повторяющиеся data[i][name])
- UA-заголовок присутствует в headers переданных в Client
"""
import urllib.parse
features = [_make_feature("enc-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
captured_post_kwargs: list[dict] = []
captured_client_kwargs: list[dict] = []
def _make_client(**kwargs: Any) -> MagicMock:
captured_client_kwargs.append(dict(kwargs))
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
captured_post_kwargs.append(post_kwargs)
return _mock_response(payload)
mc.post.side_effect = _post
return mc
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
assert len(captured_post_kwargs) == 1
post_kw = captured_post_kwargs[0]
# content= должен быть, data= — нет
assert "content" in post_kw, "post() должен использовать content=, а не data="
assert "data" not in post_kw, "post() не должен передавать data= (баг с tuple-encoding)"
# content — корректная urlencoded-строка
body = post_kw["content"]
assert isinstance(body, str), f"content должен быть str, получили {type(body)}"
parsed = urllib.parse.parse_qs(body, keep_blank_values=True)
assert "data[0][name]" in parsed
assert "data[0][value]" in parsed
assert "data[1][name]" in parsed
assert "data[1][value]" in parsed
assert parsed["data[0][value]"] == ["Екатеринбург"]
assert parsed["data[1][value]"] == ["1"]
# UA в заголовках клиента
client_headers = captured_client_kwargs[0].get("headers", {})
assert "User-Agent" in client_headers
assert "Mozilla" in client_headers["User-Agent"]
def test_build_form_data_category_type_as_string() -> None:
"""category_type конвертируется в строку (httpx data= ожидает str)."""
result = _build_form_data("Свердловская", 4)
values = dict(result)
assert values["data[1][value]"] == "4"
# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────
_DETAIL_HTML_DL = """
<html><body>
<dl class="okn-card">
<dt>Наименование</dt><dd>Дом Севастьянова</dd>
<dt>Адрес</dt><dd>г. Екатеринбург, пр. Ленина, 35</dd>
<dt>Категория историко-культурного значения</dt><dd>федерального</dd>
<dt>Вид объекта</dt><dd>памятник архитектуры</dd>
<dt>Датировка</dt><dd>1866</dd>
<dt>Документ о постановке на гос. охрану</dt><dd>Постановление Правительства РФ № 527</dd>
<dt>Регистрационный номер</dt><dd>661510234190006</dd>
</dl>
</body></html>
"""
_DETAIL_HTML_TABLE = """
<html><body>
<table class="okn">
<tr><td>Наименование</td><td>Гостиница «Большой Урал»</td></tr>
<tr><td>Адрес</td><td>г. Екатеринбург, ул. Красноармейская, 1</td></tr>
<tr><td>Категория историко-культурного значения</td><td>регионального</td></tr>
</table>
</body></html>
"""
def test_parse_detail_html_dl_extracts_all_fields() -> None:
"""<dl><dt>/<dd> структура → все поля извлечены."""
result = _parse_detail_html(_DETAIL_HTML_DL)
assert result["name"] == "Дом Севастьянова"
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
assert result["protection_category"] == "федерального"
assert result["object_kind"] == "памятник архитектуры"
assert result["dating"] == "1866"
assert result["protection_doc"] == "Постановление Правительства РФ № 527"
assert result["regnumber"] == "661510234190006"
def test_parse_detail_html_table_extracts_name_address() -> None:
"""<table><tr><td>label</td><td>value</td></tr> → парсится."""
result = _parse_detail_html(_DETAIL_HTML_TABLE)
assert result["name"] == "Гостиница «Большой Урал»"
assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
assert result["protection_category"] == "регионального"
def test_parse_detail_html_empty_returns_empty_dict() -> None:
"""Пустой/None HTML → {}."""
assert _parse_detail_html("") == {}
assert _parse_detail_html(" ") == {}
def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
"""HTML без полей-маркеров → пустой dict."""
html = "<html><body><p>404 not found</p></body></html>"
assert _parse_detail_html(html) == {}
def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
"""GET карточки + парсинг → {name, address, …}."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_detail("661510234190006")
assert result["name"] == "Дом Севастьянова"
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
# GET был вызван с params={"id": "..."}.
mock_client.get.assert_called_once()
call_kwargs = mock_client.get.call_args.kwargs
assert call_kwargs.get("params") == {"id": "661510234190006"}
def test_fetch_okn_detail_http_error_returns_empty() -> None:
"""HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response("", status=500)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep"),
):
result = fetch_okn_detail("001")
assert result == {}
def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
"""Пустой source_id → пустой dict без HTTP-вызова."""
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
result = fetch_okn_detail("")
assert result == {}
mock_cls.assert_not_called()
def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
"""SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
import httpx
clients: list[dict] = []
def _make_client(**kwargs: Any) -> MagicMock:
verify = kwargs.get("verify", True)
clients.append({"verify": verify})
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
if verify:
mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
else:
mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
return mc
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
result = fetch_okn_detail("test-id")
assert result["name"] == "Дом Севастьянова"
assert len(clients) == 2
assert clients[0]["verify"] is True
assert clients[1]["verify"] is False
# ── WAF-hygiene: rate-limit + 429/5xx backoff (#2445 item D3) ────────────────
def test_fetch_okn_points_sleeps_between_requests_for_rate_limit() -> None:
"""Второй вызов fetch_okn_points почти сразу после первого → time.sleep вызван
(rate-limit держит паузу между последовательными запросами к WAF-хосту).
"""
payload = _make_geojson_response([_make_feature("rl-001", 56.83, 60.61)])
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response(payload)
import app.services.scrapers.okn_egrkn_client as client_mod
# Форсируем "последний запрос был только что" — следующий вызов обязан поспать.
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_points("Екатеринбург", 1)
mock_sleep.assert_called_once()
(slept_s,) = mock_sleep.call_args.args
assert 0 < slept_s <= client_mod._RATE_LIMIT_MS / 1000.0
def test_fetch_okn_detail_shares_rate_limit_with_searchmap() -> None:
"""Rate-limit общий между searchMap и detail — один WAF-хост okn-mk.mkrf.ru."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
import app.services.scrapers.okn_egrkn_client as client_mod
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_detail("test-id")
mock_sleep.assert_called_once()
def test_do_request_backs_off_on_429_then_succeeds() -> None:
"""429 → экспоненциальный backoff (time.sleep) → retry → успех на второй попытке."""
features = [_make_feature("429-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
responses = [_mock_response({}, status=429), _mock_response(payload)]
# Первая ответка raise_for_status не должна вызываться — код читает status_code
# напрямую и уходит в backoff-branch до raise_for_status().
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
assert result[0]["id"] == "429-001"
assert call_count["n"] == 2
# Backoff перед повтором вызван хотя бы раз (плюс возможные rate-limit sleeps).
assert mock_sleep.call_count >= 1
def test_do_request_gives_up_after_max_retries_on_persistent_429() -> None:
"""Устойчивый 429 → ретраи исчерпаны (_MAX_RETRIES), возврат [] без raise."""
import app.services.scrapers.okn_egrkn_client as client_mod
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=429)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert result == []
# attempt=0..._MAX_RETRIES-1 включительно ретраятся → _MAX_RETRIES backoff-sleeps
# (плюс rate-limit sleeps на каждую из _MAX_RETRIES+1 попыток).
assert mock_sleep.call_count >= client_mod._MAX_RETRIES
def test_do_request_retry_after_header_respected_and_capped() -> None:
"""Retry-After заголовок уважается для расчёта backoff (капается _MAX_BACKOFF_S)."""
import app.services.scrapers.okn_egrkn_client as client_mod
huge_retry_after = str(int(client_mod._MAX_BACKOFF_S) + 500)
resp_429 = _mock_response({}, status=429)
resp_429.headers = {"Retry-After": huge_retry_after}
resp_ok = _mock_response(_make_geojson_response([_make_feature("ra-001", 56.83, 60.61)]))
resp_ok.headers = {}
responses = [resp_429, resp_ok]
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
# Backoff-sleep вызывался хотя бы раз с длительностью <= _MAX_BACKOFF_S
# (Retry-After капается потолком, а не используется как есть).
backoff_calls = [c.args[0] for c in mock_sleep.call_args_list]
assert any(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
assert all(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)