diff --git a/backend/app/services/scrapers/okn_egrkn_client.py b/backend/app/services/scrapers/okn_egrkn_client.py
index c701229c..73ffcf6c 100644
--- a/backend/app/services/scrapers/okn_egrkn_client.py
+++ b/backend/app/services/scrapers/okn_egrkn_client.py
@@ -19,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При 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
import logging
import re
+import time
import urllib.parse
from typing import Any
@@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
_REQUEST_TIMEOUT = 30.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.
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
# автоматически — нужно прописать вручную. Конфликта с 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].
"""
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
@@ -123,19 +173,37 @@ def _do_request(
form_data: list[tuple[str, str]],
*,
verify: bool,
+ attempt: int = 0,
) -> 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 гарантированно кодирует список кортежей с повторяющимися ключами
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
# «expected bytes, tuple found». content= передаёт тело как raw str.
body = urllib.parse.urlencode(form_data)
+ _wait_rate_limit()
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
resp = client.post(
_SEARCHMAP_URL,
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()
except httpx.HTTPStatusError as exc:
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
@@ -145,7 +213,7 @@ def _do_request(
logger.warning(
"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)
return []
except Exception as exc:
@@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
"""
if not source_id:
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]:
- """HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
+def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
+ """HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
+
+ WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap — один WAF-хост),
+ на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
+ """
params = {"id": source_id}
+ _wait_rate_limit()
try:
with httpx.Client(
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
) as client:
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()
except httpx.HTTPStatusError as exc:
logger.warning(
@@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
logger.warning(
"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)
return {}
except Exception as exc:
diff --git a/backend/app/workers/tasks/okn_objects_sync.py b/backend/app/workers/tasks/okn_objects_sync.py
index 0021821e..28d5f755 100644
--- a/backend/app/workers/tasks/okn_objects_sync.py
+++ b/backend/app/workers/tasks/okn_objects_sync.py
@@ -25,6 +25,13 @@ UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fe
``db.begin_nested()``.
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
@@ -89,6 +96,12 @@ _DETAIL_FETCH_LIMIT = 1500
# Логгировать прогресс detail-fetch каждые N объектов.
_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("""
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
VALUES (
@@ -145,6 +158,7 @@ def _enrich_with_detail(
*,
detail_fetcher: Any = fetch_okn_detail,
limit: int = _DETAIL_FETCH_LIMIT,
+ fail_breaker: int = _DETAIL_FAIL_BREAKER,
) -> int:
"""Обогатить raw_props detail-полями (name, address, …).
@@ -154,15 +168,25 @@ def _enrich_with_detail(
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` — детали
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
+ Пэйсинг и 429/5xx backoff — на уровне HTTP-клиента (``okn_egrkn_client``),
+ общий rate-limit между списком и detail-запросами. Circuit breaker здесь
+ (#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД,
+ завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после
+ исчерпания ретраев клиента) → прерываем цикл, не долбим оставшийся лимит.
+ Успешный вызов (даже с пустым detail — объект без карточки) сбрасывает
+ счётчик подряд-фейлов.
+
Args:
to_upsert: список row-dict для UPSERT (мутируется in-place).
detail_fetcher: callable source_id → dict (для DI в тестах).
limit: максимум detail-вызовов за прогон.
+ fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла.
Returns:
Число фич, для которых detail-поля были успешно подмёрджены.
"""
enriched = 0
+ consecutive_fail = 0
total = min(len(to_upsert), limit)
for idx, row in enumerate(to_upsert[:limit], 1):
try:
@@ -174,6 +198,19 @@ def _enrich_with_detail(
exc,
)
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:
continue
@@ -224,6 +261,10 @@ def sync_okn_objects() -> dict[str, int]:
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
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:
category = _CATEGORY_MAP[category_type]
logger.info(
diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py
index 5fec43e4..6d1740c6 100644
--- a/backend/tests/services/scrapers/test_okn_egrkn_client.py
+++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py
@@ -8,6 +8,9 @@
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки → {name, address, …}; graceful при ошибках.
- _parse_detail_html:
/- /
- и
/| структура парсится; маркеры label.
+- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
+ экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
+ исчерпываются на устойчивой ошибке без raise.
"""
from __future__ import annotations
@@ -109,6 +112,9 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> 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
@@ -124,6 +130,8 @@ def _mock_html_response(html: str, status: int = 200) -> 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
@@ -170,16 +178,21 @@ def test_fetch_okn_points_empty_features() -> None:
def test_fetch_okn_points_http_error_returns_empty() -> None:
- """HTTP 500 → пустой список (не raise)."""
+ """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):
+ 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:
@@ -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:
- """HTTP 500 на detail → пустой dict, не raise."""
+ """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):
+ 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 == {}
@@ -482,3 +498,149 @@ def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
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)
diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py
index e12d7d91..466ed6dd 100644
--- a/backend/tests/workers/tasks/test_okn_objects_sync.py
+++ b/backend/tests/workers/tasks/test_okn_objects_sync.py
@@ -10,6 +10,8 @@
- _enrich_with_detail: merge name/address в raw_props; пустой detail → noop.
- _enrich_with_detail: detail-фейл (raise) не валит прогон.
- _enrich_with_detail: limit ограничивает число fetch'ей.
+- _enrich_with_detail: circuit breaker — N детейл-фейлов подряд прерывает цикл,
+ сбрасывается успехом, дефолт берётся из _DETAIL_FAIL_BREAKER (#2445 item D3).
- parcel_okn_objects: name/address возвращаются из БД-строки.
"""
@@ -139,10 +141,9 @@ def test_address_passes_include_ekb_districts() -> None:
"Верх-Исетский",
"Железнодорожный",
}
- assert expected_districts <= set(_ADDRESS_PASSES), (
- f"Не все районы ЕКБ в _ADDRESS_PASSES: missing="
- f"{expected_districts - set(_ADDRESS_PASSES)}"
- )
+ assert expected_districts <= set(
+ _ADDRESS_PASSES
+ ), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
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"
+# ── 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:
"""limit=N → только первые N source_id запрошены."""
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,
"raw_props": json.dumps(["broken", "list"]), # не dict
}
- enriched = _enrich_with_detail(
- [row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"}
- )
+ enriched = _enrich_with_detail([row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"})
assert enriched == 1
parsed = json.loads(row["raw_props"])
assert parsed == {"name": "X", "address": "Y"}
|