После выхода новой iOS 15.0 пользователей СберМаркета выкидывало из приложения после блокировки телефона. Бэкенд возвращал ошибку 403 — «пользователь не авторизован». На поиск причины ушло два месяца.


Евгений Рядовой и Дмитрий Шлюгаев из команды разработки рассказали, как искали ошибку и почему это было так трудно.


Сентябрь: ищем причину разлогинов в бэкенде


В сентябре 2021 года клиенты СберМаркет стали жаловаться на неожиданные разлогины. Например, человек собирал корзину товаров в приложении и на 15 минут оставлял телефон. После разблокировки корзина пропадала и приложение требовало авторизацию. Нужно было понять, когда и почему появляется этот баг.


До первых сообщений о разлогинах произошло два события: Apple выпустила iOS 15.0 и наши разработчики обновили модуль старта приложения. Сначала мы решили, что проблема появилась как раз из-за нарушения API-контракта с бэкендом, потому что ошибки были и на Android, и на iOS.


Через логи мы нашли проблему в методах API, которые использовали для запроса к серверу. Их исправили, и к концу сентября баги с авторизацией на Android сократились до уровня статистической погрешности.


В 20-х числах сентября проблема появилась снова, но уже на iOS. Ребята из команды стали искать решение на официальном форуме разработчиков Apple. Там было несколько постов с похожей проблемой, но технические специалисты Apple на них не отвечали. Максимум — отсылали в документацию по новой версии iOS. Стало понятно, что искать решение надо самим.



Одна из веток с описанием похожей проблемы на форуме Apple


Октябрь: ищем причину разлогина на iOS


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


Примерно через неделю тестов тестировщики заметили закономерность в логах:


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


В логах видно, что приложение отправило запрос к серверу и получило в ответ ошибку


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


В приложении СберМаркета пользователи логинятся с помощью приватного токена. Он хранится в Keychain, доступ к которому ограничен политиками безопасности. Они максимально строгие: токены доступны только на разблокированном устройстве и только когда приложение активно.


По какой-то причине приложение отправляло запрос к серверу на заблокированном устройстве и пыталось получить токены из Keychain. Из-за настроек Keychain сервер не отдавал токен и затирал его как непригодный. Когда пользователь включал приложение, сервер получал пустой токен и возвращал ошибку авторизации 403.


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


Ноябрь: нашли реальную причину разлогина


Через два месяца после релиза iOS 15.0 на форумах и в документации появилось сообщение о новой фиче — прогреве приложения в бэкграунде.



Из описания в документации непонятно, как работает фича. Чтобы приложение быстрее запускалось, ОС стартует его в бэкграунде. Но при каких условиях это происходит, не описано


Если коротко, iOS отслеживает, как часто пользователь запускает то или иное приложение. Она может предварительно выполнить код приложения до вызова UIApplicationMain(), чтобы сократить время старта. Это происходит независимо от того, заблокирован экран устройства или нет. Как именно работает прогрев, описал Филипп Красновид на Medium.


Мы поняли, почему пользователи вылетали из приложения СберМаркета. Запрос токена находился в самом начале кода, до вызова UIApplicationMain(). iOS запускала прогрев, и приложение пыталось получить токен.


Чтобы решить проблему, пришлось переработать схему запуска приложения. В новой версии при прогреве не отправляются никакие запросы, потому что проверка токена происходит после вызова UIApplicationMain().


Также мы подключили флаг UIApplication.shared.state == .background, который указывает, что приложение запущено в бэкграунд-режиме. При установленном флаге, в том числе в режиме прогрева, приложение не может отправлять запросы к серверу.


Как быстрее понять причину ошибки в новой версии iOS


Разработка под iOS — это всегда немного лотерея. Apple не раскрывает, как именно работают фичи ОС. Даже та документация, которая есть на сайте Apple сейчас, появилась не сразу.


Если бы проблема возникла только у СберМаркета, можно было бы подумать, что команда не следила за обновлениями ОС. Но проблема разлогинов возникала у многих. Например, о похожих багах после релиза iOS 15.0 говорили пользователи AliExpress. А разработчик приложения Cookpad описал похожий кейс на Source Diving.


Чтобы быстрее отлавливать баги и находить их причины, у нас есть несколько советов. Они достаточно просты, но с загадочной iOS только так.


Тестировать приложения на бета-версии ОС. За несколько месяцев до релиза новой iOS выходит бета для разработчиков и тестировщиков. Она может отличаться от итоговой версии, но самые важные фичи в ней всё равно будут.


С прогревом она не спасла — эта функция не была указана как ключевая фича, а документация обновилась не сразу после релиза.


Штормить с командой тестировщиков неожиданные тест-кейсы. Разработчики были уверены, что приложение физически не может отправлять запросы на сервер при заблокированном экране. Чтобы избежать подобной ситуации, можно придумать расширенные тест-кейсы, продумать все возможные и невозможные сценарии.


Следить за форумами разработчиков. После релиза iOS на официальном форуме Apple, 4PDA и Stack Overflow довольно быстро появляются новые ветки с вопросами и описанием багов. Иногда там можно заметить ошибку, на которую ещё не напоролись пользователи и тестировщики вашего приложения.


Смотреть и слушать экспертов. Например, в подкасте Dub Dub DC от TechByTwo Бен Глостер и Джастин Уильямс разбирают новые фичи и особенности каждого релиза. В выпусках подкаста можно найти зацепку для нового тест-кейса или решения проблемы, с которой вы уже столкнулись.




Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK.

Комментарии (6)


  1. t-nick
    23.06.2022 18:11
    +7

    Дайте угадаю. У вас есть некий singleton god object, который грузит кучу всего при инициализации, которая при этом не ленивая и происходит в +load? Иначе трудно объяснить выполнение какого-либо кода до вызова UIApplicationMain()


    1. ooki2day
      23.06.2022 18:57
      +5

      согласен. заголовок врет - сломало не обновление ios, а кривая архитектура приложения


    1. DevilDimon
      23.06.2022 20:52
      +1

      Почему же, создаёте main.swift и пишете там что угодно, а потом зовёте UIApplicationMain. Делать так, конечно, не надо, но это же вполне возможно без load/initialize, конструкторов динамических либ и прочей изотерики. При этом создаваемый контейнер может быть и не синглтоном, а инициализировать свои поля может и лениво. А сделать контейнер, не являющийся god object, ещё надо постараться - это для подавляющего числа приложений (даже сложных) просто непрактично.


      1. t-nick
        23.06.2022 23:04

        Лезть в main - последнее дело. Для этого нужны очень веские причины. Об этом не пишут на StackOverflow. Я представлял скорее среднего иосника злоупотребляющего антипаттернами, который легко мог бы допустить подобный косяк.


      1. t-nick
        23.06.2022 23:14

        Что касается DI-контейнера (если вы его имели в виду), то оный не является god object, так как выполняет одну единственную задачу - построение графа объектов. При условии избегания антипаттерна сайд эффекта в инициализаторе, всё будет нормально.


    1. EvgeniyRyadovoy Автор
      24.06.2022 09:11

      God-объекта у нас нет, но верно подмечено, что был собственный DI контейнер. К сожалению, по недосмотру, некоторые объекты инициализировались гораздо раньше необходимого, что и привело к таким результатам. После этой ситуации мы вынесли некоторые уроки и стали чуточку более опытными :)