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"}