Уведомления на Android без Google: почему UnifiedPush через публичный ntfy у вас не работает

от автора

Как вы помните мы делаем мессенджер. Google-сервисы в нашем основном регионе недоступны, значит FCM отпадает, что мы с этим сделали?

Стандартный ответ на эту задачу известен и выглядит красиво: UnifiedPush. Открытый контракт, никакого Google, пользователь сам выбирает, через кого получать сигналы. Мы так и сделали в июне, всё заработало на тестовых устройствах, и мы поехали дальше.

Полтора месяца спустя пошли жалобы, что уведомления не приходят. Мы полезли в логи прода и обнаружили, что четыре из пяти попыток разбудить устройство заканчивались отказом, и так было с первого дня работы фичи. Ниже разбор: почему так, почему платный тариф это не лечит, и что мы в итоге написали сами. Цифры настоящие, из журнала боевого сервера.

Как устроен UnifiedPush, в двух абзацах

Если коротко: UnifiedPush разносит доставку на три роли. Есть ваше приложение, есть ваш сервер и есть дистрибьютор, отдельная программа на телефоне, которая держит связь с каким-то пуш-сервером.

Приложение через системный бродкаст просит у дистрибьютора эндпоинт. Дистрибьютор отвечает обычным HTTPS-адресом, приложение отправляет этот адрес вашему серверу. Дальше сервер, когда надо разбудить телефон, просто делает POST на этот адрес. Пуш-сервер доставляет байты дистрибьютору, дистрибьютор системным бродкастом отдаёт их приложению. Всё, никакого Google в цепочке.

Самый популярный дистрибьютор это ntfy, а самый популярный пуш-сервер это его публичный экземпляр ntfy.sh. Именно на этой связке и стоит по умолчанию почти всё, что использует UnifiedPush. Мы тоже начали с неё.

Что показали логи

Наш сервер логирует каждую попытку разбудить устройство. Вот срез за 36 часов, только по Android-эндпоинтам:

исход попытки

сколько

доставлено

100

507 Insufficient Storage

351

429 Too Many Requests

69

400 Bad Request

19

То есть на каждую успешную доставку приходилось больше четырёх отказов.

Первая реакция была «что-то сломалось на той стороне». Мы подняли весь журнал, который был на сервере, и оказалось хуже: отказы идут с 15 июня, то есть со дня, когда фича вышла. Ничего не ломалось, оно никогда толком и не работало, просто на паре тестовых телефонов, которые всё время на связи, это было незаметно.

Дальше три кода, три разные причины. Разбираю каждую, потому что все три неочевидны и все три бьют именно по продакшену, а не по тестам.

507: «сначала подпишись, потом публикуй»

Код 507 для публикации выглядит странно, пока не прочитаешь текст ошибки:

cannot publish to UnifiedPush topic without previously active subscriber

Идём в исходники ntfy, в обработчик публикации:

if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {    // UnifiedPush clients must subscribe before publishing to allow proper    // subscriber-based rate limiting.    return nil, errHTTPInsufficientStorageUnifiedPush.With(t)}

Читается так: если это UnifiedPush-топик, и на сервере включено лимитирование по подписчику, и прямо сейчас на этот топик нет зарегистрированного подписчика, то публикация отвергается. Не откладывается, не кэшируется, а отвергается. Сообщения не существует.

Теперь следите за руками: официальное приложение ntfy для топиков на ntfy.sh в сборке из Google Play использует Firebase. Постоянное соединение (в интерфейсе это называется instant delivery) там опциональное, всегда включено оно только в сборке с F-Droid. Логика разработчиков ntfy понятна и разумна: зачем жечь батарею постоянным сокетом, если есть FCM.

Собираем вместе. В регионе, где FCM не работает, пользователь ставит ntfy из Play, тот ждёт сигнала через мёртвый Firebase и постоянного соединения с ntfy.sh не держит. С точки зрения сервера подписчика на топике нет. Наш POST получает 507. Пуш не просто теряется, он даже не сохраняется, чтобы дойти позже.

То есть связка «UnifiedPush + ntfy из Play + ntfy.sh» в регионе без Google по умолчанию не работает вообще, и узнать об этом можно только из кода сервера, потому что снаружи всё выглядит настроенным.

429: лимит списывается не с вас, а с получателя

Дальше интереснее. Обычный 429 от чужого сервиса читается однозначно: вы слишком часто стучитесь, притормозите. Мы честно сели считать, укладываемся ли мы в лимиты ntfy.sh (по умолчанию это ведро на 60 запросов, которое пополняется со скоростью один запрос в 5 секунд), и получили, что при нашей нагрузке должны укладываться с запасом.

Оказалось, ведро не наше. Смотрим middleware, через который проходит публикация:

func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {    return func(w http.ResponseWriter, r *http.Request, v *visitor) error {        t, err := s.topicFromPath(v, r.URL.Path)        if err != nil {            return err        }        vrate := v        if rateVisitor := t.RateVisitor(); rateVisitor != nil {            vrate = rateVisitor        }        ...        } else if !vrate.RequestAllowed() {            return errHTTPTooManyRequestsLimitRequests        }

v это тот, кто делает запрос, то есть мы. Но если у топика есть rate visitor, то есть подписчик, лимит проверяется у подписчика. Это осмысленное решение со стороны ntfy: иначе один отправитель мог бы выесть общий лимит на всех. Но для нас последствия оказались тяжёлыми.

Подписчик опознаётся по IP. А наши пользователи это в основном мобильный интернет, где сотни абонентов сидят за одним NAT оператора. Для ntfy.sh все они один и тот же посетитель с одним ведром на 60 запросов. Достаточно одного сообщения в группу, чтобы наш сервер отправил пачку пушей, ведро схлопнулось, и дальше отказ получают все, кто оказался за тем же NAT, включая тех, кому мы вообще ничего не слали.

Отдельно отмечу симптом, по которому это можно опознать у себя: у нас 37 из 59 проблемных эндпоинтов в течение одних суток получали и 429, и 507. Состояние мигает, поэтому «то работает, то нет» в жалобах пользователей это не преувеличение, а точное описание.

400: обязательный заголовок, о котором легко не знать

Третий код бил только по части эндпоинтов, и все они были на updates.push.services.mozilla.com. Это значит, что у пользователя стоял не ntfy, а дистрибьютор на базе WebPush.

WebPush это RFC 8030, и там заголовок TTL в запросе обязателен. Не «желателен», а обязателен, сервер вправе отказать без него. Мы его не отправляли, потому что ntfy на него не смотрит, а больше мы ничего и не пробовали. В результате у этих пользователей пуши не работали никогда, ни одной секунды, а в логах это выглядело как редкий безобидный 400.

Мораль простая: если вы публикуете в UnifiedPush-эндпоинт, вы не знаете, что там за пуш-сервер на другом конце. Отправляйте корректный с точки зрения WebPush запрос всегда.

Content-Type: application/jsonTTL: 86400Urgency: high

Почему платный тариф не спасает

Первая мысль после такого разбора: ну хорошо, купим у ntfy.sh платный аккаунт и будем ходить с токеном, там лимиты выше.

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

Это, кстати, хороший общий вывод: перед тем как платить за проблему, найдите в коде то место, которое вас отвергает, и проверьте, смотрит ли оно вообще на деньги.

Что мы сделали: свой пуш-сервер

Вывод из всего перечисленного получился неутешительный: публичный ntfy.sh нашей аудитории служить не может, и никакими настройками на нашей стороне это не чинится. Значит, нужен свой пуш-сервер, где обе ручки наши.

Сам ntfy для этого прекрасно подходит, он для того и open source. Развернули рядом с бэкендом, вот значимая часть конфига:

base-url: "https://push.example.app"listen-http: "127.0.0.1:2586"behind-proxy: true# Держим сигнал для устройства, которое спит или без сетиcache-file: "/var/lib/ntfy/cache.db"cache-duration: "12h"# Вот это и есть источник 507. Выключено.visitor-subscriber-rate-limiting: false# Ведро, рассчитанное на групповую рассылку,# и наш собственный адрес, освобождённый от него совсемvisitor-request-limit-burst: 200visitor-request-limit-replenish: "1s"visitor-request-limit-exempt-hosts: "127.0.0.1, БЛАБЛА_АДРЕС"visitor-message-daily-limit: 0# Веб-морда и аккаунты нам не нужны, это лишняя поверхностьweb-root: "disable"enable-signup: falseenable-login: false

Проверили ровно те три сценария, на которых горели:

  • публикация в топик, на котором никогда не было подписчика, отвечает 200, а не 507, и сообщение лежит в кэше, пока телефон не появится;

  • 120 публикаций подряд одним потоком дают 120 ответов 200, ни одного 429;

  • сигнал, отправленный пока телефон в авиарежиме, доезжает после возвращения сети.

Что мы сделали: свой дистрибьютор внутри приложения

Своего сервера мало. Пользователю по-прежнему надо поставить отдельное приложение, зайти в его настройки и вписать туда наш адрес. Для мессенджера, который человек ставит, чтобы просто переписываться, это стена, о которую молча теряется половина людей.

Поэтому дистрибьютор мы написали свой, прямо внутри приложения. Спецификация UnifiedPush это разрешает явно, такой вариант называется embedded distributor.

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

  • org.unifiedpush.android.distributor.REGISTER с полями application и token;

  • ...UNREGISTER с token;

  • ...MESSAGE_ACK с token и id.

Дистрибьютор отвечает приложению:

Практическая деталь для тех, кто полезет повторять: точные имена полей мы в итоге доставали из самой библиотеки-коннектора, распаковав .aar и вытащив строковые константы из класс-файлов. Это быстрее, чем сверять документацию с реальностью.

Дальше два компонента. Первый это обычный BroadcastReceiver, объявленный в манифесте с действием REGISTER (это заодно то, что делает приложение видимым в списке дистрибьюторов, приложение имеет право быть дистрибьютором самому себе):

val topic = ensureTopic(ctx, token)          // up + 16 случайных символов из SecureRandomval endpoint = "$PUSH_HOST/$topic?up=1"ctx.sendBroadcast(Intent(ACTION_NEW_ENDPOINT).apply {    `package` = ctx.packageName    putExtra(EXTRA_TOKEN, token)    putExtra(EXTRA_ENDPOINT, endpoint)})

Обратите внимание, что путь эндпоинта и есть весь секрет: кто знает имя топика, тот может разбудить это устройство. Поэтому топик генерируется криптостойким генератором, а не Random.

Второй компонент это foreground-служба с одним WebSocket до своего пуш-сервера. Постоянное уведомление в шторке это цена вопроса, Android не разрешает держать фоновое соединение без него, и ровно за это же платит ntfy.

// ntfy отдаёт события построчным JSON, нас интересует одноwhen (str("event")) {    "message" -> {        val body = str("message") ?: return        deliver(body, str("id"))   // тот самый MESSAGE-бродкаст в приложение    }    else -> Unit                   // open и keepalive просто подтверждают, что сокет жив}

Важная мелочь, которая экономит пользователю пропущенные сообщения: при переподключении мы передаём серверу since с идентификатором последнего доставленного сообщения. Тогда всё, что прилетело, пока телефон был офлайн, доезжает из кэша сервера, а не теряется.

Ниже по цепочке в приложении не изменилось ничего. Сигнал приходит тем же бродкастом, что раньше приходил от ntfy, через ту же библиотеку-коннектор, в тот же обработчик. Это, пожалуй, главный аргумент за embedded-дистрибьютор вместо своего велосипеда с нуля: вы меняете только транспорт, а весь код, который строит уведомления и разбирает payload, остаётся как был. И пользователь при этом не теряет выбор: ntfy остаётся в списке и переключиться можно в любую сторону.

Два бага, которые видно только на устройстве

Оба выглядят очевидно в пересказе и оба не видны при чтении кода. Мы поймали их на эмуляторе за десять минут живого теста.

Смена топика. Повторная регистрация выдаёт новый топик, а служба продолжала слушать старый. Сокет живой, логи чистые, состояние «подключено», и ни одного сигнала. Лечится сравнением активного топика с сохранённым при каждом старте службы, но искать это по логам «всё хорошо» неприятно.

Реконнект-двойник. Когда мы намеренно закрываем устаревший сокет, у него срабатывает onClosed. Обработчик не отличал это от обрыва сети и планировал переподключение поверх только что созданного. Получалось два живых сокета и каждое уведомление приходило дважды. Лечится счётчиком поколений: слушатель знает свой номер и молча игнорирует всё, если он уже не актуален.

Если будете делать похожее, заложите время на прогон именно на устройстве. Ни один из этих двух багов не ловится ни компилятором, ни юнит-тестом на чистых функциях.

Пуш-сокет должен ходить теми же путями, что и всё остальное

Последний пункт, до которого мы дошли не сразу, и он важен всем, кто работает в условиях блокировок.

У нашего приложения есть встроенный обход блокировок, весь трафик при необходимости уходит в туннель. А пуш-сокет мы сначала сделали обычным клиентом, и он оказался единственным соединением в приложении, прибитым к прямому маршруту. То есть при блокировке пуш-домена уведомления умерли бы первыми, ровно в тот момент, когда человеку важнее всего узнать, что ему написали.

Тонкость реализации: OkHttp фиксирует маршрут в момент создания клиента, поэтому недостаточно один раз спросить «включён ли туннель» при старте службы. Клиент надо пересоздавать при каждой смене транспорта, плюс уметь переподключаться по требованию, потому что туннель поднимается на секунду позже, чем сокет успевает набрать при запуске приложения.

Честно про границы

Всё вышеописанное не делает пуши на Android без Google такими же надёжными, как FCM. Стоит понимать, за что вы платите.

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

Агрессивное энергосбережение остаётся вашим противником. Doze и фирменные «оптимизаторы» некоторых вендоров усыпляют соединение, и это ровно та же проблема, что у любого дистрибьютора, включая ntfy. Force stop убивает вас так же, как всех.

Свой пуш-сервер видит метаданные: какой топик будят, когда и как часто. У нас он стоит рядом с бэкендом, который и так это знает, так что новой стороны в цепочке не появляется. Если вы поднимаете его отдельно, помните, что это ещё один наблюдатель.

И, разумеется, свой пуш-домен это ещё один адрес, который могут заблокировать. Именно поэтому предыдущий пункт про туннель не косметика.

Что стоит забрать из этого текста

Замерьте, сколько пушей у вас реально доходит. Не «работает ли уведомление на телефоне разработчика», а доля успешных ответов по всем эндпоинтам за сутки. Мы полтора месяца были уверены, что фича работает, а отказов было вчетверо больше, чем доставок.

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

Свой дистрибьютор это меньше работы, чем кажется. Несколько бродкастов, одна foreground-служба и один WebSocket. Зато исчезают и чужие лимиты, и требование поставить второе приложение, а весь код обработки уведомлений остаётся нетронутым.


Всё описанное живёт в нашем мессенджере RCQ, Android-клиент открыт: github.com/rcq-messenger/rcq-android. Смотреть имеет смысл каталог push/embedded, там ровно те три файла, о которых речь.

Если вы держите UnifiedPush в проде и мерили доставку, расскажите, какие цифры видите вы. Особенно интересно, как ведут себя дистрибьюторы на WebPush, у нас их слишком мало, чтобы делать выводы.

ссылка на оригинал статьи https://habr.com/ru/articles/1065510/