fix(scrapers): rate-limit/backoff/circuit-breaker для ОКН EGRKN-скрапера (#2445-D3) (#2461)
This commit is contained in:
parent
464519c69b
commit
53b160ff0a
4 changed files with 385 additions and 18 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue