fix(scrapers): rate-limit/backoff/circuit-breaker для ОКН EGRKN-скрапера (#2445-D3) (#2461)
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

This commit is contained in:
bot-backend 2026-07-05 20:09:18 +00:00
parent 464519c69b
commit 53b160ff0a
4 changed files with 385 additions and 18 deletions

View file

@ -19,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена. TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242). При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
import time
import urllib.parse import urllib.parse
from typing import Any from typing import Any
@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
_REQUEST_TIMEOUT = 30.0 _REQUEST_TIMEOUT = 30.0
_DETAIL_TIMEOUT = 10.0 _DETAIL_TIMEOUT = 10.0
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
_RATE_LIMIT_MS = 400
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
_MAX_RETRIES = 3
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
# поэтому база/потолок меньше, но идиома та же).
_RETRY_BACKOFF_BASE_S = 2.0
_MAX_BACKOFF_S = 60.0
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
# WAF без пауз даже при чередовании вызовов.
_last_request_ts: float = 0.0
def _wait_rate_limit() -> None:
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
Общий для searchMap и detail обе цели один и тот же WAF-хост.
"""
global _last_request_ts
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
if elapsed_ms < _RATE_LIMIT_MS:
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
_last_request_ts = time.time()
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
"""Секунды ожидания перед повтором после 429/5xx.
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
"""
if retry_after_hdr and retry_after_hdr.isdigit():
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
# Browser headers -- required: without User-Agent the server returns 403. # Browser headers -- required: without User-Agent the server returns 403.
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его # Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется. # автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
@ -99,7 +149,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0]. Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
""" """
form_data = _build_form_data(address, category_type) form_data = _build_form_data(address, category_type)
features = _do_request(form_data, verify=True) features = _do_request(form_data, verify=True, attempt=0)
return features return features
@ -123,19 +173,37 @@ def _do_request(
form_data: list[tuple[str, str]], form_data: list[tuple[str, str]],
*, *,
verify: bool, verify: bool,
attempt: int = 0,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.""" """Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
"""
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами # urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple] # (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает # конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
# «expected bytes, tuple found». content= передаёт тело как raw str. # «expected bytes, tuple found». content= передаёт тело как raw str.
body = urllib.parse.urlencode(form_data) body = urllib.parse.urlencode(form_data)
_wait_rate_limit()
try: try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client: with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
resp = client.post( resp = client.post(
_SEARCHMAP_URL, _SEARCHMAP_URL,
content=body, content=body,
) )
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
resp.status_code,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_request(form_data, verify=verify, attempt=attempt + 1)
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc) logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
@ -145,7 +213,7 @@ def _do_request(
logger.warning( logger.warning(
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc "okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
) )
return _do_request(form_data, verify=False) return _do_request(form_data, verify=False, attempt=attempt)
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc) logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
return [] return []
except Exception as exc: except Exception as exc:
@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
""" """
if not source_id: if not source_id:
return {} return {}
return _do_detail_request(source_id, verify=True) return _do_detail_request(source_id, verify=True, attempt=0)
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]: def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.""" """HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap один WAF-хост),
на 429/5xx экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
"""
params = {"id": source_id} params = {"id": source_id}
_wait_rate_limit()
try: try:
with httpx.Client( with httpx.Client(
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
) as client: ) as client:
resp = client.get(_DETAIL_URL, params=params) resp = client.get(_DETAIL_URL, params=params)
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: detail HTTP %d source_id=%s "
"(попытка %d/%d), retry через %.1fs",
resp.status_code,
source_id,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
logger.warning( logger.warning(
@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
logger.warning( logger.warning(
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc "okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
) )
return _do_detail_request(source_id, verify=False) return _do_detail_request(source_id, verify=False, attempt=attempt)
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc) logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
return {} return {}
except Exception as exc: except Exception as exc:

View file

@ -25,6 +25,13 @@ UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fe
``db.begin_nested()``. ``db.begin_nested()``.
Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00). Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00).
WAF-hygiene (#2445 item D3): пэйсинг и 429/5xx backoff между HTTP-запросами живут
в ``okn_egrkn_client`` (``_wait_rate_limit`` общий для 33 list-проходов и до
``_DETAIL_FETCH_LIMIT`` detail-запросов, один WAF-хост). Здесь, в
``_enrich_with_detail``, circuit breaker: ``_DETAIL_FAIL_BREAKER`` детейл-фейлов
ПОДРЯД прерываем detail-loop (не долбим WAF до конца лимита при устойчивом бане),
аналогично ``gisogd66.py`` (``_CARD_FAIL_BREAKER``).
""" """
from __future__ import annotations from __future__ import annotations
@ -89,6 +96,12 @@ _DETAIL_FETCH_LIMIT = 1500
# Логгировать прогресс detail-fetch каждые N объектов. # Логгировать прогресс detail-fetch каждые N объектов.
_DETAIL_PROGRESS_EVERY = 100 _DETAIL_PROGRESS_EVERY = 100
# Circuit breaker (#2445 item D3, аналогично gisogd66._CARD_FAIL_BREAKER): столько
# detail-фейлов ПОДРЯД (raise ИЛИ пустой detail считается "не подряд", см. ниже)
# → прерываем detail-loop. WAF-бан обычно даёт устойчивую серию ошибок; долбить
# оставшиеся до ~1500 detail-запросов бессмысленно и рискованно для IP-репутации.
_DETAIL_FAIL_BREAKER = 20
_UPSERT_SQL = text(""" _UPSERT_SQL = text("""
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at) INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
VALUES ( VALUES (
@ -145,6 +158,7 @@ def _enrich_with_detail(
*, *,
detail_fetcher: Any = fetch_okn_detail, detail_fetcher: Any = fetch_okn_detail,
limit: int = _DETAIL_FETCH_LIMIT, limit: int = _DETAIL_FETCH_LIMIT,
fail_breaker: int = _DETAIL_FAIL_BREAKER,
) -> int: ) -> int:
"""Обогатить raw_props detail-полями (name, address, …). """Обогатить raw_props detail-полями (name, address, …).
@ -154,15 +168,25 @@ def _enrich_with_detail(
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` детали Записи без detail-полей сохраняются с тем же ``raw_props={id}`` детали
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит). могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
Пэйсинг и 429/5xx backoff на уровне HTTP-клиента (``okn_egrkn_client``),
общий rate-limit между списком и detail-запросами. Circuit breaker здесь
(#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД,
завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после
исчерпания ретраев клиента) прерываем цикл, не долбим оставшийся лимит.
Успешный вызов (даже с пустым detail объект без карточки) сбрасывает
счётчик подряд-фейлов.
Args: Args:
to_upsert: список row-dict для UPSERT (мутируется in-place). to_upsert: список row-dict для UPSERT (мутируется in-place).
detail_fetcher: callable source_id dict (для DI в тестах). detail_fetcher: callable source_id dict (для DI в тестах).
limit: максимум detail-вызовов за прогон. limit: максимум detail-вызовов за прогон.
fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла.
Returns: Returns:
Число фич, для которых detail-поля были успешно подмёрджены. Число фич, для которых detail-поля были успешно подмёрджены.
""" """
enriched = 0 enriched = 0
consecutive_fail = 0
total = min(len(to_upsert), limit) total = min(len(to_upsert), limit)
for idx, row in enumerate(to_upsert[:limit], 1): for idx, row in enumerate(to_upsert[:limit], 1):
try: try:
@ -174,6 +198,19 @@ def _enrich_with_detail(
exc, exc,
) )
detail = {} detail = {}
consecutive_fail += 1
if consecutive_fail >= fail_breaker:
logger.error(
"okn_objects_sync: %d detail-фейлов подряд — прерываю "
"detail-loop (устойчивый бан/сбой WAF), обработано %d/%d",
fail_breaker,
idx,
total,
)
break
continue
else:
consecutive_fail = 0
if not detail: if not detail:
continue continue
@ -224,6 +261,10 @@ def sync_okn_objects() -> dict[str, int]:
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL. # Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
to_upsert: list[dict[str, Any]] = [] to_upsert: list[dict[str, Any]] = []
# Пэйсинг между 33 list-запросами (#2445 item D3): fetch_okn_points → _do_request
# → okn_egrkn_client._wait_rate_limit перед КАЖДЫМ POST (общий rate-limit с
# detail-запросами, один WAF-хост okn-mk.mkrf.ru). Ничего дополнительно спать
# здесь не нужно — пэйсинг/backoff инкапсулированы в клиенте.
for address, category_type in _PASSES: for address, category_type in _PASSES:
category = _CATEGORY_MAP[category_type] category = _CATEGORY_MAP[category_type]
logger.info( logger.info(

View file

@ -8,6 +8,9 @@
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=). - form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки {name, address, }; graceful при ошибках. - fetch_okn_detail: HTML карточки {name, address, }; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label. - _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 __future__ import annotations
@ -109,6 +112,9 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
resp.status_code = status resp.status_code = status
resp.json.return_value = payload resp.json.return_value = payload
resp.raise_for_status = MagicMock() resp.raise_for_status = MagicMock()
# headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
# в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
resp.headers = {}
if status >= 400: if status >= 400:
import httpx import httpx
@ -124,6 +130,8 @@ def _mock_html_response(html: str, status: int = 200) -> MagicMock:
resp.status_code = status resp.status_code = status
resp.text = html resp.text = html
resp.raise_for_status = MagicMock() resp.raise_for_status = MagicMock()
# См. _mock_response — headers должен быть настоящим dict, не MagicMock.
resp.headers = {}
if status >= 400: if status >= 400:
import httpx import httpx
@ -170,16 +178,21 @@ def test_fetch_okn_points_empty_features() -> None:
def test_fetch_okn_points_http_error_returns_empty() -> None: def test_fetch_okn_points_http_error_returns_empty() -> None:
"""HTTP 500 → пустой список (не raise).""" """HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client) mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False) mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=500) mock_client.post.return_value = _mock_response({}, status=500)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): 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) result = fetch_okn_points("Свердловская", 1)
assert result == [] assert result == []
# 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
assert mock_sleep.call_count >= 1
def test_fetch_okn_points_invalid_json_returns_empty() -> None: def test_fetch_okn_points_invalid_json_returns_empty() -> None:
@ -437,13 +450,16 @@ def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
def test_fetch_okn_detail_http_error_returns_empty() -> None: def test_fetch_okn_detail_http_error_returns_empty() -> None:
"""HTTP 500 на detail → пустой dict, не raise.""" """HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client) mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False) mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response("", status=500) mock_client.get.return_value = _mock_html_response("", status=500)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): 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") result = fetch_okn_detail("001")
assert result == {} assert result == {}
@ -482,3 +498,149 @@ def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
assert len(clients) == 2 assert len(clients) == 2
assert clients[0]["verify"] is True assert clients[0]["verify"] is True
assert clients[1]["verify"] is False 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)

View file

@ -10,6 +10,8 @@
- _enrich_with_detail: merge name/address в raw_props; пустой detail noop. - _enrich_with_detail: merge name/address в raw_props; пустой detail noop.
- _enrich_with_detail: detail-фейл (raise) не валит прогон. - _enrich_with_detail: detail-фейл (raise) не валит прогон.
- _enrich_with_detail: limit ограничивает число fetch'ей. - _enrich_with_detail: limit ограничивает число fetch'ей.
- _enrich_with_detail: circuit breaker N детейл-фейлов подряд прерывает цикл,
сбрасывается успехом, дефолт берётся из _DETAIL_FAIL_BREAKER (#2445 item D3).
- parcel_okn_objects: name/address возвращаются из БД-строки. - parcel_okn_objects: name/address возвращаются из БД-строки.
""" """
@ -139,10 +141,9 @@ def test_address_passes_include_ekb_districts() -> None:
"Верх-Исетский", "Верх-Исетский",
"Железнодорожный", "Железнодорожный",
} }
assert expected_districts <= set(_ADDRESS_PASSES), ( assert expected_districts <= set(
f"Не все районы ЕКБ в _ADDRESS_PASSES: missing=" _ADDRESS_PASSES
f"{expected_districts - set(_ADDRESS_PASSES)}" ), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
)
def test_address_passes_order_city_before_district() -> None: def test_address_passes_order_city_before_district() -> None:
@ -413,6 +414,84 @@ def test_enrich_tolerates_detail_exception() -> None:
assert parsed_1["name"] == "OK" assert parsed_1["name"] == "OK"
# ── circuit breaker: N detail-фейлов подряд → прерывание (#2445 item D3) ────
def test_enrich_circuit_breaker_stops_after_consecutive_failures() -> None:
"""fail_breaker детейл-фейлов ПОДРЯД → цикл прерывается, остаток не запрашивается.
Аналогично gisogd66._CARD_FAIL_BREAKER: устойчивый WAF-бан не должен приводить
к тому, что все оставшиеся до _DETAIL_FETCH_LIMIT объекты долбятся впустую.
"""
rows = [_row(f"{i:03d}") for i in range(20)]
calls: list[str] = []
def _fetcher(source_id: str) -> dict[str, Any]:
calls.append(source_id)
raise RuntimeError("WAF ban simulated")
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, fail_breaker=5)
assert enriched == 0
# Breaker останавливает цикл ровно после fail_breaker вызовов подряд — остаток
# (15 строк) НЕ запрашивается.
assert len(calls) == 5
def test_enrich_circuit_breaker_resets_on_success() -> None:
"""Успешный вызов (даже без detail-полей) сбрасывает счётчик подряд-фейлов.
Чередование fail/success/fail/... никогда не должно достичь fail_breaker,
если между фейлами есть успехи.
"""
rows = [_row(f"{i:03d}") for i in range(12)]
calls: list[str] = []
def _fetcher(source_id: str) -> dict[str, Any]:
calls.append(source_id)
idx = int(source_id)
if idx % 2 == 0:
raise RuntimeError("transient")
return {"name": f"N-{source_id}"}
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, fail_breaker=3)
# Ни одного случая 3 фейлов подряд — breaker не сработал, все 12 строк обработаны.
assert len(calls) == 12
assert enriched == 6
def test_enrich_circuit_breaker_disabled_via_high_threshold() -> None:
"""fail_breaker выше числа строк → breaker не мешает нормальному прогону с фейлами."""
rows = [_row(f"{i:03d}") for i in range(5)]
def _fetcher(source_id: str) -> dict[str, Any]:
raise RuntimeError("always fails")
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, fail_breaker=1000)
assert enriched == 0
# Без early-stop все 5 строк должны были быть попытаны — raw_props не изменился.
for row in rows:
parsed = json.loads(row["raw_props"])
assert "name" not in parsed
def test_enrich_uses_default_fail_breaker_constant() -> None:
"""Дефолт fail_breaker берётся из _DETAIL_FAIL_BREAKER (модульная константа)."""
from app.workers.tasks.okn_objects_sync import _DETAIL_FAIL_BREAKER
rows = [_row(f"{i:03d}") for i in range(_DETAIL_FAIL_BREAKER + 10)]
calls: list[str] = []
def _fetcher(source_id: str) -> dict[str, Any]:
calls.append(source_id)
raise RuntimeError("persistent failure")
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher)
assert enriched == 0
assert len(calls) == _DETAIL_FAIL_BREAKER
def test_enrich_respects_limit() -> None: def test_enrich_respects_limit() -> None:
"""limit=N → только первые N source_id запрошены.""" """limit=N → только первые N source_id запрошены."""
rows = [_row(f"{i:03d}") for i in range(10)] rows = [_row(f"{i:03d}") for i in range(10)]
@ -437,9 +516,7 @@ def test_enrich_handles_non_dict_base_props() -> None:
"lat": 56.83, "lat": 56.83,
"raw_props": json.dumps(["broken", "list"]), # не dict "raw_props": json.dumps(["broken", "list"]), # не dict
} }
enriched = _enrich_with_detail( enriched = _enrich_with_detail([row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"})
[row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"}
)
assert enriched == 1 assert enriched == 1
parsed = json.loads(row["raw_props"]) parsed = json.loads(row["raw_props"])
assert parsed == {"name": "X", "address": "Y"} assert parsed == {"name": "X", "address": "Y"}