Разобрали и почини ли систему уведомлений в Glitch

Последние дни разбирались, почему пуши иногда приходили с задержкой, иногда вообще не приходили, а иногда сыпались пачкой после того, как телефон снова выходил в сеть. Разобрал сервер по кускам, нашёл семь конкретных причин — ни одна не гипотеза, каждая подтверждена в коде. Рассказываю, что там было и как исправили.

1. Каждый пуш заново авторизовывался в Google — и клал весь сервер

Токен для отправки в Google Firebase (FCM) запрашивался у Google на каждое отправляемое сообщение:

def _get_access_token() -> str: credentials = google.oauth2.service_account.Credentials.from_service_account_file(...) credentials.refresh(google.auth.transport.requests.Request()) return credentials.token

Проблема в том, что это синхронный, блокирующий HTTP-запрос внутри асинхронного сервера. Пока он выполнялся (100–500 мс), зависал вообще весь event loop — то есть подвисали запросы всех пользователей в этот момент, не только того, кому шёл пуш. А токен Google живёт целый час — мы же дёргали его сотни раз за этот час без всякой необходимости.

Исправили: токен теперь кэшируется и обновляется за 5 минут до истечения, сам запрос обновления вынесен в отдельный поток (asyncio.to_thread), под блокировкой, чтобы избежать гонки состояний. После фикса первый запрос — 90 мс, все последующие — 0 мс.

2. Рассылка уведомлений шла по одному человеку, друг за другом, без таймаута

async def send_push_to_users(tokens, title, body, data=None): for token in tokens: await send_push(token, title, body, data) # один за другим

Если пост в канале читали 100 подписчиков — сервер делал 100 последовательных запросов к FCM один за другим. И всё это происходило прямо внутри запроса того, кто написал пост: он физически ждал, пока разошлётся всё до последнего человека. Плюс сессия для запросов создавалась заново на каждый пуш, без таймаута — а дефолтный таймаут в этом случае 5 минут.

Исправили: одна переиспользуемая сессия с таймаутом 10 секунд, отправка параллельно с ограничением на 20 одновременных запросов, а главное — вся рассылка перенесена в фоновую задачу. Автор поста получает ответ мгновенно, не дожидаясь отправки уведомлений.

3. Ошибки отправки просто исчезали в никуда

except Exception: return False

Ни одного лога. Если пуш не доходил — узнать причину было физически невозможно. А токены удалённых приложений (Google в этом случае отвечает статусом UNREGISTERED) никогда не удалялись из базы — сервер слал уведомления в пустоту, и база копила мусор.

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

4. Онлайн на одном устройстве глушил уведомления на всех остальных

Раньше система смотрела: онлайн ли пользователь вообще, по всем его устройствам сразу. Если дома открыт планшет с активным подключением — телефон в кармане не получал пуш, хотя человек его физически не видел. Плюс была вторая проблема: сервер понимал, что устройство отключилось, только после двух неотвеченных пингов по 40 секунд — то есть до 80 секунд человек считался "онлайн" и не получал уведомления в принципе, без единого повтора отправки.

Исправили: фильтр стал смотреть не на пользователя целиком, а на конкретную сессию/устройство — пуш не уходит только на то устройство, которое реально держит открытое соединение прямо сейчас. Интервал пинга сократили с 40 до 25 секунд — окно "ложного онлайна" упало с ~80 до ~50 секунд.

5. Красивый код группировки уведомлений на самом деле никогда не запускался

В коде был написан целый обработчик, который должен был склеивать несколько уведомлений от одного чата в одну пачку ("3 новых сообщения"), и в комментарии стояла надежда, что фоновый обработчик Flutter его подхватит. По документации Google это в принципе так не работает: если в push-сообщении есть notification-поле, а приложение свёрнуто — фоновый обработчик приложения не вызывается вообще, уведомление рисует сама система. Отсюда и эффект "пачки": каждое сообщение прилетало отдельной строкой, а после периода офлайна всё это разом вываливалось в шторку.

Исправили иначе — не бороться с системой, а использовать её штатный механизм: каждому пушу присваивается tag вида chat_5 или channel_12. Android сам заменяет уведомление с одинаковым tag вместо того, чтобы добавлять новое — получается один живой чат = одно обновляющееся уведомление. Если непрочитанных несколько, в заголовке подставляется счётчик ("Стас · 3"), который считается на сервере одним SQL-запросом. Работает на любой прошивке, включая Huawei/Honor, где раньше всё было особенно нестабильно.

6. Токен устройства для пушей отправлялся на сервер один раз — и если не долетал, никто об этом не узнавал

Future<void> _sendToken(String token) async { try { await api.updateFcmToken(token); } catch (_) {} }

Одна попытка при запуске приложения. Если в этот момент моргнула сеть — человек оставался без уведомлений вообще, до следующего перезапуска приложения, и никакого сигнала об этом не было.

Исправили: добавили флаг "токен доставлен/не доставлен" и повторную отправку при каждом успешном переподключении WebSocket — то есть именно в тот момент, когда сеть гарантированно есть.

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

1