Поддержка: доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет #3459

Merged
lekss361 merged 1 commit from fix/tg-support-db-and-ratelimit into main 2026-09-12 08:00:19 +00:00
Owner

Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки. Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост — эти сами ручки. Закрывает скоуп «пофикси телеграм» целиком, кроме вебхука GlitchTip, исключённого пользователем явно.

1. Сбой БД уже ПОСЛЕ доставки в топик

Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут ничем, в отличие от шага отправки. SQLAlchemyError там означал: сообщение оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в пустоту: треда в БД нет, и мост на реплай пишет только WARNING.

Обе ручки теперь ловят SQLAlchemyError вокруг блока БД, тихо откатывают сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ спровоцировал бы ровно тот дубль, которого избегаем.

Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её только на успехе: треда нет, но идентичность посетителя обязана пережить сбой, иначе следующее сообщение заведёт второй тред.

2. Успеха мало — клиент должен об этом узнать

Первая версия правки отдавала успех молча, и это было неотличимо от тишины. Нашло adversarial-ревью, подтверждено чтением фронта: useSupportChat.ts:115-126 выбрасывает тело POST, SupportChatPanel.tsx:163 чистит только черновик, транскрипт рендерится исключительно из messagesQuery, а баннер показывает лишь ошибки. То есть поле ввода очищается, в списке пусто, предупреждения нет — пользователь шлёт снова, и получается тот самый дубль.

SupportMessageOut получил поле persisted со значением True по умолчанию: все существующие пути и GET /support/messages отдают его без изменений. На пути деградации приходит False, и панель показывает рядом с композером предупреждение:

Сообщение получено оператором, но не сохранилось в переписке — поэтому его нет в списке выше. Отправлять ещё раз не нужно.

Баннер гаснет на следующей нормально записанной отправке. Анонимный виджет рендерит ту же панель и получает поведение автоматически.

Текст предупреждения оператору тоже переписан — он больше не рассчитывает на то, что клиент напишет снова, и прямо говорит, что ответить через бота не получится.

3. Рейт-лимит переставал считаться при недоступном Telegram

retry_after() — это peek, а record() звался только на успехе. Верно для «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит, лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org, не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы выесть лимиты группы ровно тогда, когда канал и так еле жив.

Добавлен отдельный счётчик отказов на тех же ключах: 5 подряд в окне 30с включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять подряд на живом канале практически недостижимы, а reset() на успехе стирает историю — считаем именно подряд. Тридцать секунд заведомо короче реальной недоступности, так что после восстановления пользователя не наказывают. Основной «успешный» бюджет и non-destructive peek не тронуты.

SlidingWindowLimiter.reset(key) добавлен аддитивно, с оговоркой в докстринге, что лимитерам-бюджетам он противопоказан.

Осознанное ограничение: барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного последовательного клиента — один отказавший запрос сам занимает до 23с, и пять таких в окно не укладываются. Ловить одиночку значило бы наказывать обычного пользователя за чужую аварию. Это не регресс: раньше барьера не было вовсе.

Тесты

Отказ БД в обеих ручках: клиент получает успех с persisted=False, оператору уходит предупреждение, текст обращения в него не попадает, 500 не возникает. Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и существующий рейт-лимит не изменились.

Бэкенд: 117 passed, ruff check и ruff format --check чистые. Фронт: tsc --noEmit чистый, next lint без новых замечаний (два предупреждения — предсуществующие, в чужих файлах).

🤖 Generated with Claude Code

https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs

Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки. Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост — эти сами ручки. Закрывает скоуп «пофикси телеграм» целиком, кроме вебхука GlitchTip, исключённого пользователем явно. ## 1. Сбой БД уже ПОСЛЕ доставки в топик Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение оператору **доставлено**, а клиент получил 500. Дальше по цепочке — пользователь шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в пустоту: треда в БД нет, и мост на реплай пишет только WARNING. Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают клиенту **успех**. Успех, а не отказ: доставка правда состоялась, и отказ спровоцировал бы ровно тот дубль, которого избегаем. Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её только на успехе: треда нет, но идентичность посетителя обязана пережить сбой, иначе следующее сообщение заведёт второй тред. ## 2. Успеха мало — клиент должен об этом узнать Первая версия правки отдавала успех молча, и **это было неотличимо от тишины**. Нашло adversarial-ревью, подтверждено чтением фронта: `useSupportChat.ts:115-126` выбрасывает тело POST, `SupportChatPanel.tsx:163` чистит только черновик, транскрипт рендерится исключительно из `messagesQuery`, а баннер показывает лишь ошибки. То есть поле ввода очищается, в списке пусто, предупреждения нет — пользователь шлёт снова, и получается тот самый дубль. `SupportMessageOut` получил поле `persisted` со значением `True` по умолчанию: все существующие пути и `GET /support/messages` отдают его без изменений. На пути деградации приходит `False`, и панель показывает рядом с композером предупреждение: > Сообщение получено оператором, но не сохранилось в переписке — поэтому его нет в списке выше. Отправлять ещё раз не нужно. Баннер гаснет на следующей нормально записанной отправке. Анонимный виджет рендерит ту же панель и получает поведение автоматически. Текст предупреждения оператору тоже переписан — он больше не рассчитывает на то, что клиент напишет снова, и прямо говорит, что ответить через бота не получится. ## 3. Рейт-лимит переставал считаться при недоступном Telegram `retry_after()` — это peek, а `record()` звался только на успехе. Верно для «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит, лимита нет **вообще**, и каждый повтор стоит до четырёх попыток к `api.telegram.org`, не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы выесть лимиты группы ровно тогда, когда канал и так еле жив. Добавлен отдельный счётчик отказов на тех же ключах: **5 подряд в окне 30с** включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять подряд на живом канале практически недостижимы, а `reset()` на успехе стирает историю — считаем именно подряд. Тридцать секунд заведомо короче реальной недоступности, так что после восстановления пользователя не наказывают. Основной «успешный» бюджет и non-destructive peek не тронуты. `SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге, что лимитерам-бюджетам он противопоказан. **Осознанное ограничение:** барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного последовательного клиента — один отказавший запрос сам занимает до 23с, и пять таких в окно не укладываются. Ловить одиночку значило бы наказывать обычного пользователя за чужую аварию. Это не регресс: раньше барьера не было вовсе. ## Тесты Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору уходит предупреждение, текст обращения в него не попадает, 500 не возникает. Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и существующий рейт-лимит не изменились. Бэкенд: **117 passed**, `ruff check` и `ruff format --check` чистые. Фронт: `tsc --noEmit` чистый, `next lint` без новых замечаний (два предупреждения — предсуществующие, в чужих файлах). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs
lekss361 added 1 commit 2026-09-12 07:54:27 +00:00
fix(support): доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
10ffa93a1c
Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки.
Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост; эти — сами ручки.

## Сбой БД уже ПОСЛЕ доставки в топик

Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут
ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение
оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь
шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в
пустоту, потому что треда в БД нет и мост на реплай пишет только WARNING.

Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают
сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают
клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ
спровоцировал бы ровно тот дубль, которого избегаем.

Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её
только на успехе: треда нет, но идентичность посетителя обязана пережить сбой,
иначе следующее сообщение заведёт второй тред.

## Успеха мало — клиент должен об этом узнать

Первая версия правки отдавала успех молча, и это было неотличимо от тишины.
Фронт выбрасывает тело POST и рендерит переписку только из GET, а сообщения там
нет: поле ввода очищается, в списке пусто, баннера нет. Пользователь решает, что
не отправилось, и шлёт снова — тот самый дубль. Нашло adversarial-ревью, и это
подтверждено чтением `useSupportChat.ts` и `SupportChatPanel.tsx`.

Поэтому `SupportMessageOut` получил поле `persisted` со значением `True` по
умолчанию — все существующие пути и `GET /support/messages` отдают его без
изменений. На пути деградации приходит `False`, и панель показывает рядом с
композером предупреждение: сообщение получено оператором, но в переписке его не
будет, отправлять ещё раз не нужно. Баннер гаснет на следующей нормально
записанной отправке. Анонимный виджет рендерит ту же панель и получает это
поведение автоматически.

Текст предупреждения оператору тоже переписан: он больше не рассчитывает на то,
что клиент напишет снова, и прямо говорит, что ответить через бота не получится.

## Рейт-лимит переставал считаться при недоступном Telegram

`retry_after()` — это peek, а `record()` звался только на успехе. Верно для
«не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит,
лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org,
не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы
выесть лимиты группы ровно тогда, когда канал и так еле жив.

Добавлен отдельный счётчик отказов на тех же ключах: пять подряд в окне тридцати
секунд включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять
подряд на живом канале практически недостижимы, а `reset()` на успехе стирает
историю — считаем именно подряд. Тридцать секунд заведомо короче реальной
недоступности, так что после восстановления пользователя не наказывают.
Основной «успешный» бюджет и non-destructive peek не тронуты.

`SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге,
что лимитерам-бюджетам он противопоказан.

Барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного
последовательного клиента: один отказавший запрос сам занимает до двадцати трёх
секунд, и пять таких в окно не укладываются. Это принято сознательно — ловить
одиночку значило бы наказывать обычного пользователя за чужую аварию.

## Тесты

Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору
уходит предупреждение, текст обращения в него не попадает, 500 не возникает.
Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до
Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и
существующий рейт-лимит не изменились.

Бэкенд: 117 passed, ruff чистый. Фронт: type-check чистый, lint без новых
замечаний.
lekss361 merged commit 9fa01e7ebe into main 2026-09-12 08:00:19 +00:00
lekss361 deleted branch fix/tg-support-db-and-ratelimit 2026-09-12 08:00:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3459
No description provided.