Разобрали и почини ли систему уведомлений в Glitch
Последние дни разбирались, почему пуши иногда приходили с задержкой, иногда вообще не приходили, а иногда сыпались пачкой после того, как телефон снова выходил в сеть. Разобрал сервер по кускам, нашёл семь конкретных причин — ни одна не гипотеза, каждая подтверждена в коде. Рассказываю, что там было и как исправили.
1. Каждый пуш заново авторизовывался в Google — и клал весь сервер
Токен для отправки в Google Firebase (FCM) запрашивался у Google на каждое отправляемое сообщение:
Проблема в том, что это синхронный, блокирующий HTTP-запрос внутри асинхронного сервера. Пока он выполнялся (100–500 мс), зависал вообще весь event loop — то есть подвисали запросы всех пользователей в этот момент, не только того, кому шёл пуш. А токен Google живёт целый час — мы же дёргали его сотни раз за этот час без всякой необходимости.
Исправили: токен теперь кэшируется и обновляется за 5 минут до истечения, сам запрос обновления вынесен в отдельный поток (asyncio.to_thread), под блокировкой, чтобы избежать гонки состояний. После фикса первый запрос — 90 мс, все последующие — 0 мс.
2. Рассылка уведомлений шла по одному человеку, друг за другом, без таймаута
Если пост в канале читали 100 подписчиков — сервер делал 100 последовательных запросов к FCM один за другим. И всё это происходило прямо внутри запроса того, кто написал пост: он физически ждал, пока разошлётся всё до последнего человека. Плюс сессия для запросов создавалась заново на каждый пуш, без таймаута — а дефолтный таймаут в этом случае 5 минут.
Исправили: одна переиспользуемая сессия с таймаутом 10 секунд, отправка параллельно с ограничением на 20 одновременных запросов, а главное — вся рассылка перенесена в фоновую задачу. Автор поста получает ответ мгновенно, не дожидаясь отправки уведомлений.
3. Ошибки отправки просто исчезали в никуда
Ни одного лога. Если пуш не доходил — узнать причину было физически невозможно. А токены удалённых приложений (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. Токен устройства для пушей отправлялся на сервер один раз — и если не долетал, никто об этом не узнавал
Одна попытка при запуске приложения. Если в этот момент моргнула сеть — человек оставался без уведомлений вообще, до следующего перезапуска приложения, и никакого сигнала об этом не было.
Исправили: добавили флаг "токен доставлен/не доставлен" и повторную отправку при каждом успешном переподключении WebSocket — то есть именно в тот момент, когда сеть гарантированно есть.
Бонусом исправили ещё пару мелочей: офлайн-агенты поддержки получали одно и то же сообщение дважды (от общей рассылки чата и отдельно от блока поддержки — логику дедуплицировали), и тап по уведомлению о новом посте в канале теперь действительно открывает этот канал (раньше — ничего не происходило).