Root cause of the red PR #3494 CI job (9% progress, 75s life, no error line):
test_send_message_rate_limits_across_different_topics_same_chat mocked
asyncio.sleep as a pure no-op without advancing time.monotonic. The 3rd send
(over the test's limit=2) entered TelegramGroupRateLimiter.acquire(), which
recomputes wait_s from the real, unmocked clock every iteration - since the
fake sleep never advances it, the window never expires and the while-loop
busy-spins forever instead of actually waiting, until pytest-timeout kills it.
Fixed by advancing a fake monotonic clock inside fake_sleep, matching the
already-correct pattern used by the other tests in this file.
Also added _reset_telegram_shared_client (tests/conftest.py, same pattern as
_reset_estimate_rate_limiter): app.services.tgbot.shared._client is a
module-level singleton whose rate limiter otherwise accumulates real
wall-clock timestamps across the whole pytest session, not per test.
Documented honestly in config.py: the API-role budget is shared between
support web-chat mirrors and GlitchTip alerts with no priority between them,
so a large alert burst can make the web-chat wait out its own timeout and
return 502 - flagged as a known follow-up, not fixed here.
NOTE: a full `pytest -q --timeout=60` run still hangs further into the suite,
at tests/test_glitchtip_webhook.py::test_telegram_failure_returns_502_not_500.
Not root-caused within this session's budget - the test's _fake_telegram_client
fixture correctly monkeypatches glitchtip_module.get_telegram_client, but the
anyio worker thread running the ASGI request is seen parked in a real
event-loop poll/select wait, consistent with an actual (non-mocked) sleep
somewhere in that path. Needs a follow-up session with a fresh time budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
Deep review of PR #3494 found the rate limiter unusable as designed:
- H1: acquire() waited unbounded even for interactive HTTP handlers (support.py,
glitchtip.py already pass a narrow `timeout` — reuse it as the queue wait cap
instead of editing those handlers, which are out of scope here). New
TelegramRateLimitedError (subclass of TelegramError) gives a fast, honest
502 instead of hanging past the caller's own budget.
- H2: the limiter is per-process (in-memory), but two processes write to the
same group (uvicorn API + bot worker) — giving each the same 18/min doubled
the platform ceiling. Split into telegram_group_rate_limit_api_per_minute
(12) and _bot_per_minute (6), sum kept below ~20.
- M1: bridge.py sends without an explicit timeout inherited "wait forever",
stalling the single-threaded poll loop (open DB session) past the SIGTERM
drain window. Bounded via rate_limit_max_wait=20s at the six call sites.
- M2: _locks/_sent_at grew unbounded on every unique DM chat_id. Added
opportunistic cleanup of fully-expired entries.
- L1: the "queue full" warning now logs once per acquire() call, not once
per sleep iteration.
- Corrected a factual error in the docstring: TELEGRAM_SUPPORT_CHAT_ID and
TELEGRAM_ALERTS_CHAT_ID are the SAME group on prod (topics differ only).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
Бот падал молча на каждом сообщении, если тему форума удалили/переименовали:
узнавали об этом только по отсутствию сообщений у людей. tgbot_main теперь
один раз на старте проверяет getChat + typing-индикатор с message_thread_id
(единственный способ Bot API провалидировать message_thread_id без создания
видимого сообщения) и громко пишет error при отказе, не роняя процесс.
Второе: лимит Telegram (~20 msg/min) общий на всю группу, все темы делят
бюджет — всплеск GlitchTip-алертов вместе с потоком поддержки в ту же группу
уже давал 429 и терял сообщения. TelegramGroupRateLimiter — скользящее окно
per-chat_id (НЕ per-теме) с asyncio.Lock на чат, встроен прямо в
TelegramClient._request перед _post, поэтому считает все отправки независимо
от relay/прямого пути и без изменений в support.py/glitchtip.py (они уже идут
через общий клиент). Порог настраивается через
TELEGRAM_GROUP_RATE_LIMIT_PER_MINUTE (дефолт 18, чуть ниже потолка площадки).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
Ветка fix/3471-scraper-log-levels понизила error на warning для штатных
исходов скрапинга (пустой/исчерпанный пул прокси, серия подтверждённых
блоков площадки) -- 7 тестов фильтровали caplog по ERROR и падали на
пустом списке. Поправлен только уровень фильтра/set_level, содержательные
assert'ы (streak vs ratio, отсутствие qrator/ip_rate_limited литералов,
различимость текстов "исчерпан" и "пуст") не менялись.
В test_exhausted_and_empty_pool_log_texts_are_distinct оба сценария
(пустой пул и fail-closed) теперь на одном уровне (warning) -- тест
адаптирован проверять различимость по тексту, а не по уровню.
Refs #3471
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot
на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса,
резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса,
резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота
508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит:
путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт
на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть
отправок теряет.
Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип,
что у alert-ack: без зависимостей, поднимается даже когда всё остальное
сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage,
copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется:
log_request переопределён полностью, путь редактируется до записи в лог.
Аутентификация — общий секрет в X-Relay-Secret, по образцу
X-Internal-Auth-Secret из этого же стека.
Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор
делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть
не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее
поведение без изменений, это и есть механизм отката.
Refs #3471
Deep review PR #3479 нашёл дефект в предыдущем фиксе (#3471 пункт 3): новые
direction='out' строки стали видимы резолверам (find_chat_by_topic_message,
find_thread_by_topic_message), но писались без support_chat_id. Резолверы
матчат support_chat_id IS NULL как лениентный wildcard "любой текущий чат"
(легаси-строки до 187/188) — то есть КАЖДАЯ out-строка становилась таким
wildcard. При ротации support-группы новый message_id мог бы случайно
совпасть со старой out-строкой: TG-путь увёл бы ответ ЧУЖОМУ клиенту через
copyMessage, веб-путь записал бы ответ в чужой тред. Ровно от этого
защищали миграции 187/188 (review M1).
- bridge.py: TG- и веб-ветка `_handle_group_reply` теперь передают
support_chat_id=settings.telegram_support_chat_id в record_message /
record_web_out_message (симметрично уже существующей in-ветке).
- web_support_storage.record_outbound: добавлен параметр support_chat_id,
пишется в INSERT (колонка уже существовала, DDL не нужен).
- Тест test_group_reply_to_own_previous_tg_reply_resolves_target_chat сидел
предыдущую out-строку с уже заполненным support_chat_id вручную, хотя код
писал NULL — маскировал дефект. Добавлены прямые проверки на записанное
support_chat_id (TG и веб), обе падают на прежней реализации (проверено
локальным откатом изменения — 2 failed, restore — 41 passed).
- Комментарий про "апдейт частично применён в Telegram" в except-ветке
веб-ответа был неверен для этого случая (на веб-пути ничего не уходит в
Telegram до сбоя БД) — переписан на настоящую причину: сбой БД не
переигрывается по общей политике process_update, а не из-за частичной
доставки.
Refs #3471
Два источника шума в error-ленте и логах:
1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503
"payments are disabled" из payments.py._require_enabled, которые бьёт
внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk
StarletteIntegration репортит любой HTTPException с кодом из 5xx как
error-событие, даже когда FastAPI штатно обработал исключение и вернул
корректный ответ. Добавлен before_send-фильтр
drop_payments_disabled_event (app/observability/sentry_scrub.py),
матчащий по (status_code=503, detail="payments are disabled") через
hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие
503. Подключён во всех трёх точках инициализации sentry_sdk.init
(app/main.py — единственный реальный источник события,
scheduler_main.py и tgbot_main.py — belt-and-suspenders для
единообразия, по образцу scrub_payment_request_body). Само поведение
ручки не меняется — 503 остаётся, фильтруется только репортинг в
трекер.
2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера)
не попадал ни под TimeoutException, ни под ConnectError и падал в
generic except Exception с exc_info=True — 184 строки полного
traceback в сутки на штатный провал health-пробы, хотя итоговая
сводка checked/ok/failed и так его учитывает. Добавлена отдельная
ветка except httpx.ProxyError с логом в одну строку (узел + причина
текстом исключения, без трейса). Логика самой пробы, аренды узлов и
правил пула не изменена.
Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает
целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации),
tests/services/test_proxy_pool.py (ProxyError логируется одной строкой
без exc_info, счётчики healthcheck не ломаются).
Refs #3471
Для веб-треда запись в web_support_messages(direction='out') И ЕСТЬ доставка
клиенту (веб-фронт читает её polling'ом). process_update на SQLAlchemyError
безусловно делал rollback() и всё равно сдвигал offset — Telegram апдейт
больше не отдавал, ответ оператора пропадал навсегда, а сам оператор был
уверен, что ответил. Воспроизведено на проде 31.08.2026 (клиент kopylov).
- `_handle_group_reply`: сбой БД на `record_web_out_message` теперь ловится
локально — rollback → уведомление оператору реплаем в топик, что ответ НЕ
доставлен и его нужно повторить; offset всё равно сдвигается (апдейт уже
частично применён в Telegram, переигрывать нельзя).
- `_notify_topic` возвращает bool: если само уведомление тоже упало (Telegram
недоступен), пишем `logger.error` с thread_id/message_id (без текста
переписки — ПДн в лог не идёт), чтобы это не осталось полностью немым.
- Второй дефект того же узла: `direction='out'`-строки никогда не сохраняли
topic_message_id, из-за чего реплай оператора на СВОЙ предыдущий ответ не
резолвился (маршрут держался только на зеркале клиента). Теперь TG- и
веб-путь сохраняют id ответа оператора в топике, `find_chat_by_topic_message`
/ `find_thread_by_topic_message` больше не фильтруют по direction. Колонка и
partial unique индекс уже существовали (186/187) — миграция не потребовалась.
Refs #3471
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot`
и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop.
Три причины, все подтверждены на коде и в рантайме.
## Ответ оператора мог пропасть навсегда
`process_update` заканчивался безусловным `finally: save_offset(update_id)`.
Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не
отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает
клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает
в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не
отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет
нигде, кроме строчки в логе.
Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается
`rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop`
прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение
любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram
отдаст заново.
Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно
недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного
сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с
`chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт
руками. Текст переписки в лог по-прежнему не идёт.
Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом
запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит
сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и
клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по
паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради
редкого случая; вместо неё число дублей жёстко ограничено сверху.
Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован»)
не тронута — там повтор действительно ничего не изменит.
## Таймаут задавался скаляром, поэтому connect ждал сорок секунд
`httpx.AsyncClient(timeout=effective_timeout)` разворачивается в
connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит
Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при
живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс
backoff, около трёх минут, в течение которых бот не видит ответов оператора.
В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35.
Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`,
значения в именованных константах. Запас `+10s` у `get_updates` относится к read,
докстринг поправлен.
## Клиент создавался заново на каждую попытку
`httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик
«встанет ли коннект». Для long-polling это была основная статья сетевых отказов.
Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос.
Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и
`async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`,
создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга.
`keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы
ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот
веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за
длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx
отдаёт это как `RemoteProtocolError`, который ретраится с #3457.
## Уведомления оператору шли с воркерным бюджетом внутри poll loop
Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались
без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна
такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта.
Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал
вторичного действия больше не отменяет основную ветку.
## Тесты
`tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента.
В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт
переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную
ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные
таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`,
`aclose()` его закрывает.
Прогон по затронутым файлам: 127 passed. Ruff check и format чистые.
Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика,
и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и
отдавать 502, но закрыл дыру не до конца: клиент по-прежнему выпускал наружу
сырой httpx. Ретраящийся `except` перехватывал узкий кортеж
`(httpx.TimeoutException, httpx.NetworkError)`, а `RemoteProtocolError`,
`ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники
`NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1,
не по памяти.
Практическое следствие — ровно тот отказ, который #3456 и чинил.
`RemoteProtocolError` («Server disconnected without sending a response») для
api.telegram.org из РФ — бытовой ответ, а не экзотика. Он вылетал из `_request`
сырым, проходил мимо `except TelegramError` в glitchtip.py:227 и support.py:233
и :424, и FastAPI снова отдавал 500. Глобального обработчика, который поймал бы
его выше, нет: в `core/http_errors.py` зарегистрирован только
`RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с
первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде
отличить его от исчерпания бюджета было нечем.
Теперь два `except`, и вместе они покрывают всё дерево отказов запроса.
Ретраящийся расширен до `httpx.TransportError` — тело не тронуто, те же reason,
backoff, лог и `TelegramNetworkError` из #3156. Ниже страховочный
`httpx.RequestError` без ретраев: сегодня это `DecodingError`, завтра — всё, что
httpx заведёт под `RequestError`. Порядок значим — `TransportError`
наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы
ретраиться. Повторов у страховочного нет намеренно: испорченный ответ и кривую
конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы
интерактивную ручку почти на минуту впустую.
Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте
риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот
же, что у давно ретраящегося `ReadTimeout`, политика не меняется.
Прецедент лова именно `TransportError` в этом же репозитории —
`app/services/payments/tbank_client.py:136`.
Не тронуто: ручки (они уже ловят предок), `bridge.py` (`except TelegramApiError`
там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`,
обработка 429/5xx, потолки backoff.
Тесты: прежний тест «наружу свой тип» параметризован по `ConnectTimeout`,
`RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток;
новый тест фиксирует разницу бюджета — обрыв протокола ретраится, битый ответ нет.
Прогон по четырём затронутым файлам: 80 passed.
Прод 11.09.2026, 01:35 и 01:38 MSK — два 500 на glitchtip-webhook. Причина не
в вебхуке: `TelegramClient._request` после исчерпания сетевых ретраев делал
голый `raise`, наружу летел `httpx.ConnectTimeout`. Все три HTTP-ручки ловят
`TelegramApiError` — сырой httpx пролетал мимо, и FastAPI отдавал 500 вместо
задуманного 502. Отказ площадки и её недоступность для вызывающего
неразличимы: переслать не смогли и там, и там.
Клиент больше не выпускает наружу чужой тип. Появился общий предок
`TelegramError`, под ним прежний `TelegramApiError` (ответили `ok: false`) и
новый `TelegramNetworkError` (не ответили вовсе). Раздельно, а не наследником,
потому что у сетевого отказа нет ни `error_code`, ни `description` — брать их
неоткуда, а `bridge` по `error_code == 403` разбирает «бот заблокирован» и
недоступность в этот разбор попадать не должна. Причина сохраняется в
`__cause__`: в GlitchTip по-прежнему видно, таймаут это соединения или сброс
TLS (#3156).
Три ручки — вебхук GlitchTip и обе ручки поддержки, авторизованная и
анонимная — ловят предок. Поведение воркеров не менялось: poll loop в
`bridge` и так ловит `Exception`, бюджеты ретраев те же.
Тесты: два в клиенте (свой тип наружу, причина не потеряна, это НЕ
`TelegramApiError`), три на ручках (502 на недоступности, ничего не
персистится, анонимной куки не выдаём). Четыре теста бюджета ретраев ждали
`httpx.ConnectTimeout` — ждут новый тип, проверяемые паузы прежние.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.
Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id`
была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось
ни одним запросом.
Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной
ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов,
у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как
«Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».
Теперь:
- `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка
(если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет
`proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи
лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire
при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar
(`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire`
его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик.
Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не
вправе ронять выдачу прокси или ждать на блокировке строки прогона.
- `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`,
`cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1:
формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при
нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до
штатного purge по `SOURCE_BAN_PURGE_DAYS`.
Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от
отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress`
(ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` —
все гейтятся по `banned_until > now()`.
Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by`
исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя.
Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт
базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на
гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`.
Полный прогон бэкенда: 5600 passed, 37 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
Замер на проде 27.08: `getUpdates` падает 23 раза в сутки, 14 из них за один
час. Ретрай почти всегда чинит с первой попытки, поэтому сообщений не теряется
— теряется возможность понять, что происходит:
network error (попытка 1/3): — retry через 2s
После двоеточия пусто. У httpx.ReadError и httpx.ConnectError `str(exc)` пуст,
а тип исключения в строку не попадал. По такому логу не отличить таймаут от
обрыва соединения от сброса TLS, то есть 23 события в сутки не дают ни одной
зацепки. Сеть при этом цела: сырой TLS до Telegram проходит 6 из 6 попыток
за ~0.16s.
Тип добавляется к тексту, а не вместо него: на исключениях с внятным
сообщением диагностика не должна стать беднее прежней. Оба конца закреплены
тестами — с пустым текстом и с непустым.
Closes#3156
CI поймал то, что мой локальный прогон пропустил: сьют в ПОДДИРЕКТОРИИ
tests/services/ звал удалённый _in_ekb_bbox. Граничные точки сохранены те же
(продукт-ядро 66 байт-в-байт), проверка дополнена кодом региона.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент
не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не
резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя
улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий
в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode()
с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка
«номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено
живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского
корректно остаётся неоднозначным — два разных дома в houses под одним номером).
Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR
на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не
сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы
ERROR продолжал значить настоящую проблему.
По ревью PR #2664.
1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
`lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
Теперь проверяем исходник через inspect.getsource — фальсифицировано
подстановкой копии литерала вместо импорта: тест краснеет.
2. Комментарий в location_index.py приписывал свежести чужую заслугу.
Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
не коррекция смещения, а страховка на будущее, оплаченная третью пула
(3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
но он должен быть написан как размен.
Там же задокументирован новый режим отказа: свежесть связала витрину со
здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
поломки сбора, сценарий не гипотетический.
Окно свежести не меняю — вопрос вынесен отдельно.
Refs #2660
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.
1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
девелоперский прайс (который не торгуется) формировал показываемый процент
торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).
2. location_index — предикат свежести + сегментный гард в обоих запросах
медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
тот же пул, на котором считается цена; окно свежести берётся импортом
LISTINGS_FRESH_DAYS, второго определения константы не заводим.
3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
как «всё собирается».
Refs #2660
Правки по deep-review PR #2654.
MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.
MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).
LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.
LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.
LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.
Refs #2600
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.
Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.
Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.
Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.
Refs #2600
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).
Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1),
намеренно вырезанный из общего пула через provider_affinity='domclick'
(см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё,
кроме этого одного чистого residential-адреса. Fallback-запрос из
предыдущего коммита мог законно забрать его под avito/cian/yandex,
оставив domclick (сейчас исправно собирает: 6501 активных объявлений,
368/сутки) без прокси вообще — чинили бы один источник ценой полной
поломки другого.
- acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть
ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS-
подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS
LAST, id — сохранены). Кандидат с единственным enabled-узлом своей
выделенной affinity в fallback не участвует.
- Тесты: единственный domclick-узел → acquire('avito') возвращает None;
второй enabled domclick-узел появляется — fallback снова срабатывает.
- Починен мок FakeSession (tests/services/test_proxy_pool.py):
ветка "mark_health ok" раньше ставила enabled=True безусловно по
совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в
старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy
проходил бы и против кода без реанимации. Теперь ставит enabled=True
только если в тексте SQL реально есть "enabled". Та же проблема была
и в fallback-ветке (protects_last_node переопределял логику в Python
независимо от SQL) — исправлено аналогично: применяется, только если
в SQL реально есть EXISTS-подзапрос.
Прод-замер: disabled-узлы никогда не перепроверялись (WHERE enabled в
run_proxy_healthcheck) — auto-disable по DISABLE_THRESHOLD необратим,
транзиентный сбой = вечный приговор (id 11 сгорел за ночь, будучи
физически исправным). acquire() при пустой выборке по provider_affinity
падал в None, морив источник голодом при живых свободных узлах чужой
affinity.
- run_proxy_healthcheck: disabled-узлы проверяются реже (DISABLED_RECHECK_MINUTES=60
либо last_check_at IS NULL); успешная проба реанимирует узел
(enabled=true через mark_health) и инкрементит новый счётчик revived.
- mark_health(ok=True) теперь безусловно ставит enabled=true (реанимация).
- acquire: вторым заходом при пустой выборке своей affinity берёт любой
свободный здоровый узел любой affinity (WARNING-лог), приоритет своих
сохранён.
- _probe_proxy классифицирует неуспех (timeout/connect_error/http_error/other)
в fail_kind — прокидывается в mark_health только для логирования; полноценное
разделение порогов транзиент/бан отложено (см. docstring mark_health).