Перейти к основному содержимому
Мобилка40ГЛАВА 40Отказы, повторы и выход из аккаунтаНет соединенияПовторитьпусто ≠ сломано

Отказы, повторы и выход из аккаунта

Прочитано0%
Квизы0/4
Уровень 1 · Новичок

Зачем это нужно​

Все предыдущие главы описывали, как что-то получается. Эта — про то, как всё это ломается, и про два критерия задания, которые целиком посвящены ошибкам.

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

Сюда же попадает выход из аккаунта — потому что в спецификации задания он описан запросом, который на самом деле делает не то, что кажется.

Отказы бывают трёх пород​

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 ?: "?")
}
Нажми на часть выражения
ЧЕТЫРЕ ИСХОДА ВМЕСТО ДЕСЯТКА ИСКЛЮЧЕНИЙUnknownHostExceptionNoNetworkНет соединения · ПовторитьSocketTimeoutExceptionTimeoutСервер не ответил · ПовторитьHttpException(404)Server(404)Товар не найденпрочееUnknownЧто-то пошло не такэкран знает про четыре исхода — не про исключения OkHttp и не про коды HTTP
Исключения превращаются в четыре исхода, а те — в подписи на экране
Интересный факт

Порядок веток в 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 с пустым списком. Если их не разделить, экран поиска после неудачного запроса покажет просто ничего: ни подписи, ни подсказки. Это самая частая потеря балла по критерию «поиск», причём сам поиск при этом работает правильно.

Отвечено вопросов: 0 из 3
Почему catch (e: IOException) пропустит ошибки сервера?
Ветку is IOException поставили первой в when. Что случится?
Зачем Empty — отдельное состояние, а не Content с пустым списком?
Отвечено верно: 0 из 3

Повторять или не повторять​

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

ЧТО ПОВТОРЯТЬ, А ЧТО НЕТповторять имеет смыслнет сети — она может вернутьсятаймаут — сервер мог задуматься5xx — у сервера временная бедапауза удваивается: 300 · 600 · 1200 мсповторять бессмысленно400 — тело запроса не изменится404 — записи не появится403 — прав не прибавитсятри попытки лишь задержат сообщениеповтор POST опасен вдвойне: заказ может оформиться дважды
Повтор помогает при сетевых бедах и вредит при отказах по существу
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 для обычных запросов и для загрузки файлов.

Отвечено вопросов: 0 из 3
Сколько OkHttp ждёт ответа по умолчанию?
Какой таймаут срабатывает, когда сервер получил запрос, но молчит?
Почему не стоит делать writeTimeout коротким?
Отвечено верно: 0 из 3

Выход из аккаунта​

В спецификации задания есть два запроса про сессии:

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
ЧТО ДЕЛАЕТ УДАЛЕНИЕ ЗАПИСИ СЕССИИDELETE /_authOrigins/records/{id}204 No Contentзапись об устройстве удаленасписок сессий стал пустымGET /users/records/{свой id}тем же токеном → 200 OKкорзина тем же токеном → 200 OKтокен ЖИВв спецификации запрос назван «удаление пользователя» — на деле это журнал устройствнастоящий выход — стереть токен с устройства; сервер его не отзываеттокен останется годным до своего exp — пять суток с момента выдачи
Запись сессии удалена, список пуст — а токен продолжает работать
Важно

Удаление записи _authOrigins не отзывает токен. Оно стирает отметку об устройстве — и всё. Токен остаётся годным до своего срока exp, то есть пять суток.

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

Правильный выход выглядит так:

suspend fun logout() {
store.clearToken() // токен из DataStore
store.clearUserId() // и идентификатор рядом с ним
cartCache.clear() // локальная копия корзины — иначе её увидит следующий
navigateToLogin()
}
Интересный факт

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

Отвечено вопросов: 0 из 3
Что делает DELETE /_authOrigins/records/{id}?
Как правильно сделать выход из аккаунта на этом сервере?
Какие запросы можно повторять автоматически?
Отвечено верно: 0 из 3

Типичные ошибки​

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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоThis Is My FAVORITE Error Handling ClassPhilipp Lackner
ВидеоProper Error Handling in Android & Kotlin MultiplatformAhmed Guedmioui
ВидеоSealed Class vs Sealed Interface in Kotlin – When to Use Each? | Android Interview GuideDroidStack
ВидеоIn-Depth Guide to Coroutine Cancellation & Exception Handling - Android Studio TutorialPhilipp Lackner
ВидеоError handling in Android : easy way!Renaro Santos

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.