fix(tradein/observability): close RetryError-half of GlitchTip noise fix (round 2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m30s
CI / openapi-codegen-check (pull_request) Has been skipped

Ревью round 1 подтвердил basic_auth-часть, но нашёл 4 факта в RetryError-части:

1. reraise=True в geocoder.py не убирает шум, а переименовывает: наружу летит
   httpx.HTTPStatusError, чей str() содержит ПОЛНЫЙ request URL с query string
   (`for url '...search?q=<адрес>&...'`) — воспроизведено эмпирически. Тот же
   per-address issue-explosion, просто под другим типом исключения.
   Фикс: _HTTPX_ERROR_URL_QUERY_RE в sentry_scrub.scrub_pii_event режет query
   string из httpx-style "for url '...'" сообщений — стабилизирует ТЕКСТ, не
   только тип, независимо от того, уважает ли GlitchTip fingerprint-поле.

2. stabilize_retry_error_fingerprint затирал fingerprint целиком по (типу
   причины) — RetryError из НЕСВЯЗАННЫХ подсистем с одинаковым типом причины
   схлопнулись бы в один issue (geocoder vs scraper_kit оба ловят httpx-типы).
   Фикс: culprit = event["logger"] (LoggingIntegration ставит его = имя
   модуля-источника logger.exception) идёт первым компонентом fingerprint —
   разные подсистемы больше не сливаются.

3. Второй живой источник RetryError, пропущенный round 1 (грепали литерал
   "RetryError", не producers): BaseScraper._http_get в packages/scraper-kit —
   @retry БЕЗ reraise=True, живой путь через YandexDetailScraper.fetch_detail
   (yandex/serp.py и valuation.py переопределяют _http_get без retry — не
   затронуты). Оставлен на fingerprint-хук намеренно: detail-URL варьируются
   в ПУТИ (offer id), не в query — _HTTPX_ERROR_URL_QUERY_RE их не покрывает,
   а добавление reraise=True туда воспроизвело бы ту же проблему через
   HTTPStatusError с variable path вместо query.

4. type(exc).__name__ == "RetryError" (string-compare) → isinstance(exc,
   RetryError) с прямым импортом tenacity.RetryError — не матчит посторонние
   классы с тем же __name__, не промахивается мимо подклассов.

Полный backend suite (4479 passed, 21 skipped) + geocoder/scheduler/alerts
подмножества — без регрессий (reraise=True уже влит в main).

Не тронуто (вне scope round 2, подтверждено ревьюером как верное):
ops/glitchtip-auth-forwarder/* (basic_auth 401 дроп), массовая чистка
накопленных issue.
This commit is contained in:
bot-backend 2026-08-15 20:02:44 +03:00
parent 662720e1c8
commit 349494a9df
3 changed files with 229 additions and 45 deletions

View file

@ -25,6 +25,7 @@ import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
@ -76,6 +77,31 @@ _URL_SECRET_QUERY_RE = re.compile(
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
# bakes the FULL request URL — INCLUDING query string — into the exception message:
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
# невозможные при группировке чисто по stacktrace/culprit).
#
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
# Режем query целиком (не только конкретные параметры) — host+path остаются
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
# regex (не трогает произвольные строки с `?`, см. тест
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -90,32 +116,34 @@ def _scrub(obj: Any) -> None:
_scrub(item)
def _redact_url_secrets_inplace(obj: Any) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
КАЖДОЙ строке event не ключ-based: секрет утекает через httpx span
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
request URL), а не только через известные PII-поля формы. Мутирует dict/list
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`)
сохраняет identity верхнеуровневого `event`, на что опирается контракт
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
по КАЖДОЙ строке event (не ключ-based) общий обход, переиспользуемый и для
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
содержит полный request URL), а не только через известные PII-поля формы.
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
`_redact_strings`) сохраняет identity верхнеуровневого `event`, на что
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
(`out is event`).
"""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[key] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
elif isinstance(obj, list):
for i, value in enumerate(obj):
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[i] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
@ -123,16 +151,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
error event перед отправкой.
Композиция (обе in-place, сохраняют identity `event`): (1) ключ-based
Композиция (все in-place, сохраняют identity `event`): (1) ключ-based
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
changeip #security-audit). Второй шаг не завязан на конкретные ключи полей —
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
event (не None).
changeip #security-audit), (3) full-text regex-проход, стабилизирующий httpx
error-message URL (`for url '...?...'`) убирает переменный query string
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
и т.д., где они могут оказаться независимо от
include_local_variables/traces_sample_rate. Возвращает event (не None).
"""
if not isinstance(event, dict):
return event
@ -141,7 +174,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
_redact_url_secrets_inplace(event)
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
return event
@ -184,30 +218,51 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
# теперь пробрасывает реальное исключение вместо RetryError — это ГЛАВНЫЙ фикс.
# Функция ниже — belt-and-suspenders: ловит RetryError, если он всё же всплывёт
# (ретрай-код, который забыли/не смогли пометить reraise=True), и схлопывает его
# в ОДИН persistent issue per (класс исключения-причины), а не тысячу. Fingerprint
# собран ТОЛЬКО из стабильных данных — имя типа исключения-причины (небольшой
# фиксированный словарь вроде "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ
# переменных данных запроса (адрес, IP, id объявления и т.п.), иначе проблема
# повторится в других терминах.
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
#
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue.
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
(источник, тип причины) РАЗНЫЕ источники (geocoder / scraper_kit / будущий
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
Не-RetryError события возвращает без изменений (OperationalError, алерты
scraper sweep'ов и т.п. фильтр не трогает).
`isinstance` (не сравнение `type(...).__name__` со строкой) иначе любой
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
`tenacity.RetryError` промахивался бы. Не-RetryError события возвращает без
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if exc_value is None or type(exc_value).__name__ != "RetryError":
if not isinstance(exc_value, RetryError):
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
event["fingerprint"] = ["retry-exhausted", cause_type]
culprit = event.get("logger") or event.get("transaction") or "unknown"
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
return event

View file

@ -748,9 +748,16 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный тип+стек, группируется
# нормально. Дополнительная страховка — stabilize_retry_error_fingerprint
# (app/observability/sentry_scrub.py) на случай если RetryError всплывёт откуда-то ещё.
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.

View file

@ -270,9 +270,12 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
# suspenders слой (основной фикс — reraise=True), и по контракту before_send:
# 401-класс (RetryError) схлопывается, содержательные категории (500-подобный
# generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit —
# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся
# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError)
# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные
# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории
# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
from tenacity import RetryError # noqa: E402
@ -295,18 +298,20 @@ def _raise_retry_error_from(cause: BaseException) -> RetryError:
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
"""RetryError коллапсится в persistent issue по имени типа причины — НЕ по
нестабильному str(RetryError) (repr() Future с memory address)."""
"""RetryError коллапсится в persistent issue по (culprit, имени типа причины) —
НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без
`event["logger"]` (напр. capture_exception без LoggingIntegration) culprit
падает на явный "unknown", а не пропадает из fingerprint молча."""
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "TimeoutError"]
assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
фиксированное имя типа исключения-причины (маленький словарь:
HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
culprit (logger-имя модуля) + фиксированное имя типа исключения-причины
(маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
exc = _raise_retry_error_from(
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
)
@ -315,7 +320,53 @@ def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
fingerprint_text = " ".join(out["fingerprint"])
assert "95.165.147.218" not in fingerprint_text
assert "12345" not in fingerprint_text
assert out["fingerprint"] == ["retry-exhausted", "ValueError"]
assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"]
def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None:
"""`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля,
вызвавшего logger.exception/.error) идёт в fingerprint как culprit стабильно
per-модуль, не переменные данные запроса."""
exc = _raise_retry_error_from(TimeoutError("timed out"))
out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None:
"""Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из
НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один
issue разные проблемы не сливаются, даже если типы причины совпали."""
exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out"))
exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out"))
out_geocoder = stabilize_retry_error_fingerprint(
{"logger": "app.services.geocoder"}, _hint_for(exc_geocoder)
)
out_scraper = stabilize_retry_error_fingerprint(
{"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper)
)
assert out_geocoder is not None
assert out_scraper is not None
assert out_geocoder["fingerprint"] != out_scraper["fingerprint"]
class _DecoyRetryError(Exception):
"""Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError —
но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2
claim #4) ложно матчило бы такое; isinstance — нет."""
_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc]
def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None:
"""type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только
реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук."""
exc = _DecoyRetryError("unrelated exception, same class __name__ by accident")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
@ -351,3 +402,74 @@ def test_stabilize_retry_error_no_exc_info_untouched() -> None:
def test_stabilize_retry_error_handles_non_dict_event() -> None:
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]
# ── httpx error-message URL query stabilization (glitchtip-noise review round 2,
# claim #1) ────────────────────────────────────────────────────────────────
#
# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes
# the FULL request URL — including query string — into the exception message.
# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()):
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py
# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip
# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же
# per-address issue-explosion, который reraise=True должен был устранить, просто
# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по
# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send).
_NOMINATIM_403_TEMPLATE = (
"Client error '403 Forbidden' for url "
"'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n"
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
)
def _httpx_error_event(message: str) -> dict:
return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}}
def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None:
"""Query string режется целиком из httpx-style 'for url' сообщения — host+path
остаются стабильными для группировки."""
encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb))
out = scrub_pii_event(event, {})
assert out is not None
value = out["exception"]["values"][0]["value"]
assert "search?[REDACTED]'" in value
assert "%D0%95" not in value
assert "nominatim.openstreetmap.org/search" in value # host+path сохранены
def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None:
"""Два РАЗНЫХ адреса (переменная часть query) после редактора дают
ИДЕНТИЧНЫЙ текст сообщения GlitchTip group-title больше не плодит issue
на каждый адрес (review round 2 claim #1)."""
event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1"))
event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5"))
out_a = scrub_pii_event(event_a, {})
out_b = scrub_pii_event(event_b, {})
assert out_a is not None
assert out_b is not None
assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"]
def test_scrub_pii_event_httpx_url_without_query_untouched() -> None:
"""URL без query string (напр. scraper detail page — переменная часть в
ПУТИ, не в query) остаётся нетронутым regex матчит только `?...`."""
message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'"
event = _httpx_error_event(message)
out = scrub_pii_event(event, {})
assert out is not None
assert out["exception"]["values"][0]["value"] == message
def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None:
"""Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и
кавычками не должен ложно матчиться."""
benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес"
event = {"extra": {"note": benign}}
out = scrub_pii_event(event, {})
assert out is not None
assert out["extra"]["note"] == benign