Отказы, повторы и выход из аккаунта
Зачем это нужно
Все предыдущие главы описывали, как что-то получается. Эта — про то, как всё это ломается, и про два критерия задания, которые целиком посвящены ошибкам.
Проверяют их буквально: эксперт включает самолётный режим и смотрит на экран. Приложение, которое в этот момент падает или показывает пустоту без объяснений, теряет баллы независимо от того, насколько хорошо работает при живой сети.
Сюда же попадает выход из аккаунта — потому что в спецификации задания он описан запросом, который на самом деле делает не то, что кажется.
Отказы бывают трёх пород
Retrofit сообщает о неудаче исключением, но исключения эти принципиально разные.
Первая порода: до сервера не дошли. Сети нет, адрес не разрешается, соединение оборвалось. Кода ответа нет вовсе.
── хост не существует ──
UnknownHostException → NoNetwork
Вторая: сервер не ответил вовремя. Соединение было, ответа не дождались.
── таймаут: 1 миллисекунда на всё ──
SocketTimeoutException → Timeout
Третья: сервер ответил, но отказом. Код есть, тело есть, и это уже разговор.
── ошибка сервера 404 ──
HttpException → Server(code=404, message=Not Found)
Эта разница важнее, чем кажется. UnknownHostException и SocketTimeoutException наследуются от IOException — их ловит catch (e: IOException). А HttpException от IOException не наследуется: он приходит из Retrofit, а не из сети.
Поэтому catch (e: IOException) в одиночку пропустит все ошибки сервера — самые частые. Ловить надо обе ветки, а лучше Throwable в одном месте, где происходит разбор.
Один разбор на всё приложение
Экрану не нужно знать про SocketTimeoutException. Ему нужно знать, что показать. Между ними — небольшой разбор, который пишется один раз:
sealed interface Failure {
data object NoNetwork : Failure
data object Timeout : Failure
data class Server(val code: Int, val message: String) : Failure
data class Unknown(val cause: String) : Failure
}
private fun classify(e: Throwable): Failure = when (e) {
is UnknownHostException -> Failure.NoNetwork
is SocketTimeoutException -> Failure.Timeout
is HttpException -> Failure.Server(e.code(), e.message())
is IOException -> Failure.NoNetwork
else -> Failure.Unknown(e::class.simpleName ?: "?")
}
Порядок веток в when — не мелочь. UnknownHostException и SocketTimeoutException оба наследуются от IOException. Поставьте общую ветку is IOException первой — и обе частные окажутся мёртвым кодом: сработает всегда первая подходящая. Компилятор об этом не предупредит, потому что формально код корректен.
Четыре состояния экрана
Списковый экран бывает в четырёх состояниях, и путать их нельзя: «пусто, потому что не нашли» и «пусто, потому что не дошли» — это разные экраны с разными кнопками.
/** То, во что приложение превращает любой отказ. */
sealed interface Failure {
data object NoNetwork : Failure
data object Timeout : Failure
data class Server(val code: Int) : Failure
data object Unknown : Failure
}
/** Состояние экрана: ровно четыре, и больше ничего. */
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Content<T>(val data: T) : UiState<T>
data object Empty : UiState<Nothing>
data class Error(val failure: Failure) : UiState<Nothing>
}
fun message(f: Failure): String = when (f) {
Failure.NoNetwork -> "Нет соединения. Проверьте интернет и повторите"
Failure.Timeout -> "Сервер не ответил. Попробуйте ещё раз"
is Failure.Server -> when (f.code) {
400 -> "Не получилось. Проверьте введённые данные"
404 -> "Не найдено"
in 500..599 -> "На сервере неполадки, мы уже знаем"
else -> "Ошибка ${f.code}"
}
Failure.Unknown -> "Что-то пошло не так"
}
fun canRetry(f: Failure): Boolean = when (f) {
Failure.NoNetwork, Failure.Timeout -> true
is Failure.Server -> f.code >= 500
Failure.Unknown -> false
}
fun render(state: UiState<List<String>>) = when (state) {
UiState.Loading -> " [крутилка]"
is UiState.Content -> " список: ${state.data.size} шт. ${state.data}"
UiState.Empty -> " Ничего не найдено. Измените запрос"
is UiState.Error -> " ${message(state.failure)}" +
(if (canRetry(state.failure)) " [Повторить]" else "")
}
fun main() {
val cases = listOf(
UiState.Loading,
UiState.Content(listOf("Nike Air Max 270", "Nike Air Force 1")),
UiState.Empty,
UiState.Error(Failure.NoNetwork),
UiState.Error(Failure.Timeout),
UiState.Error(Failure.Server(404)),
UiState.Error(Failure.Server(503)),
UiState.Error(Failure.Server(400)),
)
cases.forEach { s ->
println(s::class.simpleName + ":")
println(render(s))
}
}
Настоящий вывод:
Loading:
[крутилка]
Content:
список: 2 шт. [Nike Air Max 270, Nike Air Force 1]
Empty:
Ничего не найдено. Измените запрос
Error:
Нет соединения. Проверьте интернет и повторите [Повторить]
Error:
Сервер не ответил. Попробуйте ещё раз [Повторить]
Error:
Не найдено
Error:
На сервере неполадки, мы уже знаем [Повторить]
Error:
Не получилось. Проверьте введённые данные
Обратите внимание на кнопку «Повторить»: она появляется не всегда. Под ошибкой 400 её нет — повторять бессмысленно, ничего не изменится.
Empty — отдельное состояние, а не Content с пустым списком. Если их не разделить, экран поиска после неудачного запроса покажет просто ничего: ни подписи, ни подсказки. Это самая частая потеря балла по критерию «поиск», причём сам поиск при этом работает правильно.
Повторять или не повторять
Повтор — самый дешёвый способ пережить моргнувшую сеть. И самый лёгкий способ оформить заказ дважды.
private suspend fun <T> retrying(times: Int, block: suspend () -> T): T {
var wait = 300L
repeat(times - 1) { attempt ->
try { return block() } catch (e: Throwable) {
val f = classify(e)
val worthRetry = f is Failure.NoNetwork || f is Failure.Timeout ||
(f is Failure.Server && f.code >= 500)
if (!worthRetry) throw e
delay(wait)
wait *= 2
}
}
return block()
}
Живой прогон — сначала на недоступном хосте, потом на ошибке 404:
── повторы на недоступном хосте ──
попытка 1: NoNetwork, повторяем=true
попытка 2: NoNetwork, повторяем=true
сдались: NoNetwork
── повторы на ошибке 404: повтора быть не должно ──
попытка 1: Server(code=404, message=Not Found), повторяем=false
сдались сразу: Server(code=404, message=Not Found)
Автоматически повторять можно только чтение. Повтор POST — это вторая корзина, второй заказ, второй зарегистрированный пользователь. Сервер задания не умеет распознавать дубли запросов, и защиты у него никакой.
Для отправляющих запросов повтор отдают пользователю: кнопка «Повторить» на экране. Тогда решение принимает человек, и оно осознанное.
Таймауты
По умолчанию OkHttp ждёт ответа десять секунд. На демонстрации это вечность: эксперт включил самолётный режим, нажал обновление и десять секунд смотрит на крутилку.
val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.build()
Не делайте таймаут слишком коротким «для скорости». Загрузка картинки в несколько мегабайт по мобильной сети легко занимает больше восьми секунд — и оборвётся ровно в тот момент, когда эксперт проверяет загрузку фото. Разумно: короткий connectTimeout и разные readTimeout для обычных запросов и для загрузки файлов.
Выход из аккаунта
В спецификации задания есть два запроса про сессии:
GET /api/collections/_authOrigins/records — «Получение id токена»
DELETE /api/collections/_authOrigins/records/{id_token} — «Удаление пользователя»
Названия сбивают с толку, поэтому разберёмся, что это на самом деле.
_authOrigins — служебная коллекция PocketBase, где хранится журнал устройств, с которых входили. При входе создаётся запись с отпечатком устройства:
{
"collectionRef": "_pb_users_auth_",
"created": "2026-09-13 07:34:11.249Z",
"fingerprint": "ccf6ba5952e8a1071787d1264e3e4626",
"id": "08vundvfa27btfm",
"recordRef": "ned6nzkcrtqip6s"
}
Каждый видит только свои записи; попытка удалить чужую даёт 404 — знакомая маскировка запрета.
А теперь главное. Удалим запись и попробуем тем же токеном сходить за профилем:
=== удаляем сессию jyzt8jc2m2lb3ww ===
код 204
=== работает ли токен после удаления сессии? ===
код 200
=== корзина тем же токеном ===
код 200
=== сессий осталось ===
0
Удаление записи _authOrigins не отзывает токен. Оно стирает отметку об устройстве — и всё. Токен остаётся годным до своего срока exp, то есть пять суток.
Значит, «выход» в приложении — это стереть токен со своего устройства. Никакого серверного запроса для этого не нужно и не существует. Запрос из спецификации полезен для экрана «активные устройства», но выходом не является.
Правильный выход выглядит так:
suspend fun logout() {
store.clearToken() // токен из DataStore
store.clearUserId() // и идентификатор рядом с ним
cartCache.clear() // локальная копия корзины — иначе её увидит следующий
navigateToLogin()
}
Пункт про локальную корзину — не теория. Вышли, вошли другим пользователем, увидели чужие товары — это замечают сразу, и выглядит это хуже, чем любая сетевая ошибка. Всё, что относилось к прежнему пользователю, чистится в одном месте вместе с токеном.
Типичные ошибки
1. Один catch (e: IOException). Пропускает все ошибки сервера — самые частые.
2. Общая ветка is IOException выше частных. Таймаут и отсутствие сети сливаются в одно, компилятор молчит.
3. Текст исключения показан пользователю. UnknownHostException: api.matule.ru под списком товаров — это не сообщение об ошибке, а утечка внутренностей.
4. Нет состояния «пусто». Поиск, который ничего не нашёл, выглядит сломанным.
5. Автоповтор на POST. Два заказа вместо одного.
6. Таймаут по умолчанию. Десять секунд крутилки в самолётном режиме.
7. Выход через запрос к _authOrigins. Токен остаётся годным; при этом в интерфейсе всё выглядит как выход.
8. Локальные данные не очищены при выходе. Следующий пользователь видит чужую корзину.
Что засчитывает эксперт
Отдельные критерии задания про ошибки проверяются так:
- в самолётном режиме приложение не падает, а показывает понятную подпись и кнопку повтора;
- после возврата сети повтор работает, и данные появляются;
- ошибка формы показана под нужным полем, а не общим сообщением;
- поиск без результатов показывает «ничего не найдено», а не пустой экран;
- выход из аккаунта уводит на экран входа, и после него не видно чужих данных.
Проверить всё это можно за минуту, не дожидаясь эксперта: включить самолётный режим и пройти по всем экранам подряд. Обычно первым падает экран, о котором забыли, — например, карточка товара, открытая из поиска.
Куда углубиться
ConnectivityManager— узнать, есть ли сеть, до запроса и показать подсказку заранее.- Интерцептор-повторщик OkHttp: повторы на уровне клиента, а не в каждом вызове.
ResultиrunCatching— способ вернуть отказ значением, а не исключением.- Очередь отложенных действий: сохранить «добавить в корзину» и выполнить, когда сеть вернётся.
WorkManager— отправка, которая переживает закрытие приложения.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- OkHttp — таймауты и повторы — из чего состоит вызов и какие сроки на него действуют.
- Retrofit — аннотации запросов —
HttpException,Responseи работа с телом ошибки. - Kotlin — sealed-классы и интерфейсы — закрытые иерархии и исчерпывающий
when. - Мониторинг состояния сети на Android — как узнать про сеть заранее.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.