Когда сети нет: ошибки, повторы и что видит пользователь
Зачем это нужно
Код из прошлой главы написан так, будто сеть работает всегда. В жизни она не работает регулярно: метро, лифт, кончился трафик, сервер перезагружают, ответ приходит в неожиданном виде.
Разница между учебным приложением и рабочим — почти целиком в этом. Экран, который показывает список при хорошем интернете, пишет любой. Экран, который внятно объясняет, что происходит, когда интернета нет, — уже работа.
В конкурсных заданиях это отдельные критерии, и они повторяются в каждом спринте: «в случае отсутствия соединения с сетью Интернет необходимо вернуть ошибку» и «отобразить экран, указанный в макете». То есть проверяют не факт обработки, а именно то, что видит пользователь.
Три разных «не получилось»
Первое, что нужно принять: отказы бывают разной природы, и лечатся они по-разному.
class NoInternet : Exception("сеть недоступна")
class ServerError(val code: Int) : Exception("сервер ответил $code")
class BadFormat : Exception("ответ не разобрался")
sealed interface Outcome {
data class Ok(val data: String) : Outcome
data class Fail(val userText: String, val canRetry: Boolean) : Outcome
}
fun handle(e: Exception): Outcome = when (e) {
is NoInternet -> Outcome.Fail("Нет соединения. Проверьте интернет", canRetry = true)
is ServerError -> when (e.code) {
401 -> Outcome.Fail("Нужно войти заново", canRetry = false)
in 500..599 -> Outcome.Fail("Сервер недоступен, попробуйте позже", canRetry = true)
else -> Outcome.Fail("Запрос не прошёл (${e.code})", canRetry = false)
}
is BadFormat -> Outcome.Fail("Что-то пошло не так", canRetry = false)
else -> Outcome.Fail("Неизвестная ошибка", canRetry = false)
}
fun main() {
println("── один catch на всё ──")
listOf(NoInternet(), ServerError(500), ServerError(401), BadFormat()).forEach {
println(" «Ошибка загрузки» ← пользователь видит одно и то же на ${it::class.simpleName}")
}
println()
println("── разбор по типу ──")
listOf(NoInternet(), ServerError(500), ServerError(401), BadFormat()).forEach {
val r = handle(it) as Outcome.Fail
println(" ${it::class.simpleName?.padEnd(12)} → «${r.userText}», кнопка «Повторить»: ${r.canRetry}")
}
}
Живой вывод:
── один catch на всё ──
«Ошибка загрузки» ← пользователь видит одно и то же на NoInternet
«Ошибка загрузки» ← пользователь видит одно и то же на ServerError
«Ошибка загрузки» ← пользователь видит одно и то же на ServerError
«Ошибка загрузки» ← пользователь видит одно и то же на BadFormat
── разбор по типу ──
NoInternet → «Нет соединения. Проверьте интернет», кнопка «Повторить»: true
ServerError → «Сервер недоступен, попробуйте позже», кнопка «Повторить»: true
ServerError → «Нужно войти заново», кнопка «Повторить»: false
BadFormat → «Что-то пошло не так», кнопка «Повторить»: false
Обратите внимание на второй столбец: canRetry. Кнопка «Повторить» уместна не всегда. При протухшем токене повтор даст ту же ошибку сколько угодно раз — нужен вход. При сломанном формате ответа повтор тоже бессмысленен: сервер пришлёт то же самое.
| Что случилось | Кто виноват | Что показать | Повтор |
|---|---|---|---|
IOException | нет связи | «Проверьте интернет» | да |
4xx, кроме 401 | запрос неверный | что именно не так | нет |
401 | токен протух | «Войдите заново» | нет |
5xx | сервер | «Попробуйте позже» | да |
| ошибка разбора | несовпал формат | «Что-то пошло не так» | нет |
| таймаут | связь медленная | «Долго не отвечает» | да |
Где ловить
Ошибку можно поймать в трёх местах, и выбор не произвольный.
В репозитории — правильное место. Здесь известно, что за источник данных, здесь исключения сетевой библиотеки превращаются в понятные приложению типы. Наружу репозиторий отдаёт не исключение, а результат.
Здесь придётся сделать то, от чего мы страховались в главе про слои, — сменить тип возврата. До сих пор было suspend fun getProducts(): List<Product>, теперь станет DataResult<List<Product>>. Это честная цена: список перестал быть единственным возможным исходом, и тип обязан это отражать. Правится всё в двух местах — интерфейс в domain и его реализация в data, — а экран по-прежнему не знает, откуда приходят данные. Именно потому, что слои стоят, смена типа не расползается по проекту.
sealed interface DataResult<out T> {
data class Success<T>(val data: T) : DataResult<T>
data class Failure(val error: AppError) : DataResult<Nothing>
}
sealed interface AppError {
data object NoConnection : AppError
data object Unauthorized : AppError
data class Server(val code: Int) : AppError
data object Unknown : AppError
}
override suspend fun getProducts(): DataResult<List<Product>> = try {
DataResult.Success(api.getProducts().map { it.toDomain() })
} catch (e: IOException) {
DataResult.Failure(AppError.NoConnection)
} catch (e: HttpException) {
DataResult.Failure(
if (e.code() == 401) AppError.Unauthorized else AppError.Server(e.code())
)
} catch (e: Exception) {
DataResult.Failure(AppError.Unknown)
}
Во ViewModel ловить нечего — она получает готовый DataResult и переводит его в состояние экрана.
В Composable ловить нельзя вообще. Разметка рисует то, что ей дали, и не занимается обработкой.
Один catch (e: Exception) вокруг всего — не обработка, а её видимость. Он поглощает и ошибки сети, и ошибки в собственном коде: опечатка в мапперe, обращение к пустому списку, деление на ноль. Всё это превращается в «Ошибка загрузки», и вы будете искать причину вслепую. Ловите конкретное, а общий catch держите последним.
Что показать пользователю
Состояние экрана из главы про ViewModel теперь наполняется смыслом:
fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = repository.getProducts()) {
is DataResult.Success ->
_uiState.update { it.copy(isLoading = false, items = result.data) }
is DataResult.Failure ->
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
}
}
Три вещи, которые различают аккуратную работу от небрежной.
Пустой список — не ошибка. Сервер ответил успешно и вернул ноль товаров: это «ничего не найдено», отдельный экран по макету. Показывать здесь «не удалось загрузить» — неправда.
Ошибка не должна быть стектрейсом. e.toString() на экране — распространённая небрежность. Пользователю нужен текст на его языке, разработчику — тот же стектрейс, но в логах.
Кнопка «Повторить» — только там, где повтор поможет. Это тот самый canRetry из первого примера.
Повтор с нарастающей паузой
Иногда сбой временный: сеть моргнула, сервер перезагружался. Логично попробовать ещё раз — но не сразу и не бесконечно.
import kotlinx.coroutines.*
suspend fun <T> retry(times: Int, initialDelay: Long, block: suspend (Int) -> T): T {
var delayMs = initialDelay
repeat(times - 1) { attempt ->
try {
return block(attempt + 1)
} catch (e: Exception) {
println(" попытка ${attempt + 1} не прошла, ждём $delayMs мс")
delay(delayMs)
delayMs *= 2
}
}
return block(times)
}
var calls = 0
suspend fun flakyServer(attempt: Int): String {
calls++
if (calls < 3) throw RuntimeException("сервер занят")
return "данные получены с попытки $attempt"
}
fun main() = runBlocking {
println("── повтор с нарастающей паузой ──")
println(" " + retry(times = 4, initialDelay = 100) { flakyServer(it) })
println()
println(" всего обращений к серверу: $calls")
println(" без паузы это были бы 3 запроса подряд за миллисекунды")
}
── повтор с нарастающей паузой ──
попытка 1 не прошла, ждём 100 мс
попытка 2 не прошла, ждём 200 мс
данные получены с попытки 3
всего обращений к серверу: 3
без паузы это были бы 3 запроса подряд за миллисекунды
Пауза удваивается не для красоты. Если сервер перегружен, три мгновенных повтора от каждого клиента добивают его окончательно. Растущая задержка даёт время подняться.
Два ограничения. Повторяют только повторяемое — IOException и 5xx, но не 401 и не ошибку разбора. И бесконечных повторов не бывает: три-четыре попытки, дальше показываем ошибку и отдаём решение пользователю.
Таймауты
Худший вид отказа — не ошибка, а тишина. Сервер принял соединение и молчит, приложение ждёт, пользователь смотрит на индикатор. Без таймаута ожидание может длиться минуты.
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) // сколько ждём установки соединения
.readTimeout(15, TimeUnit.SECONDS) // сколько ждём ответа
.writeTimeout(15, TimeUnit.SECONDS) // сколько ждём отправки
.build()
Пятнадцать секунд — разумная отправная точка. По истечении прилетит SocketTimeoutException, который наследуется от IOException, — то есть обработается той же веткой, что и отсутствие сети. Если нужен отдельный текст «сервер долго не отвечает», ловите его до общего IOException.
Проверять связь заранее или ловить по факту
Соблазн: перед запросом спросить систему, есть ли интернет, и не ходить в сеть впустую.
На практике это ненадёжно. Система отвечает, что сеть есть, когда телефон подключён к Wi-Fi без интернета — в кафе с формой входа, в гостинице, в метро на переходе. Проверка скажет «связь есть», запрос всё равно упадёт.
Поэтому основной механизм — обработка по факту, а проверку связи используют только для уточнения текста: если система уверенно говорит, что сети нет, можно сразу написать «нет соединения», не тратя пятнадцать секунд на таймаут.
Отмена — не ошибка
Одна тонкость, на которой спотыкаются даже опытные. Когда пользователь уходит с экрана, viewModelScope отменяет корутины, и внутри бросается CancellationException. Общий catch (e: Exception) её поймает — и покажет ошибку на экране, которого уже нет.
try {
val data = api.getProducts()
} catch (e: CancellationException) {
throw e // отмену пропускаем дальше
} catch (e: Exception) {
// здесь только настоящие ошибки
}
Правило: CancellationException перехватывать нельзя, её всегда пробрасывают дальше. Отмена — штатное поведение, а не сбой.
Типичные ошибки
Пустой catch. Ошибка проглочена, экран пустой, в логах ничего. Отлаживать невозможно.
Стектрейс на экране. e.toString() в тексте ошибки — пользователь читает retrofit2.HttpException: HTTP 500.
Один catch на всё. Ошибка в мапперe выглядит как проблема сети.
Перехваченная CancellationException. Ошибка показывается при уходе с экрана.
Бесконечный повтор. Цикл while (true) с запросом — приложение греет телефон и разряжает батарею.
Нет таймаута. Индикатор крутится минутами.
Ошибка вместо пустого списка. Сервер вернул ноль товаров, а экран пишет «не удалось загрузить».
Слова главы
IOException— запрос не ушёл: нет связи, оборвалось соединение.HttpException— сервер ответил кодом не из2xx.SocketTimeoutException— истекло ожидание; наследникIOException.CancellationException— корутина отменена; не ошибка, пробрасывается дальше.DataResult— свой тип результата вместо исключений наружу.AppError— набор ошибок на языке приложения, а не библиотеки.- Повтор с нарастающей паузой — задержка удваивается с каждой попыткой.
- Таймаут — предел ожидания: соединения, ответа, отправки.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
catch (e: IOException) { DataResult.Failure(AppError.NoConnection) }
На чемпионате
Обработка отказов проверяется в каждом спринте отдельными критериями, и формулировки повторяются из года в год: «в случае отсутствия соединения с сетью Интернет необходимо вернуть ошибку» и «отобразить экран, указанный в макете».
Что делают на защите: включают авиарежим и открывают экран. Дальше смотрят:
- приложение не падает — это главное;
- показан экран из макета, а не пустое место и не стектрейс;
- есть способ повторить — кнопка или жест обновления;
- после включения сети данные появляются без перезапуска приложения.
Проверьте это сами до защиты, ровно в таком порядке. Авиарежим на эмуляторе включается за две секунды, а находит он больше, чем час чтения кода.
Что дальше
Сейчас при отсутствии сети экран пуст: показать нечего, потому что данные жили только в памяти. Но если товары уже загружались раньше, их можно было сохранить и показать даже без интернета. Следующая глава — про хранение данных на устройстве, а за ней — про кэш, который превращает «нет сети» из тупика в неудобство.
Углубиться
NetworkCapabilitiesиConnectivityManager— как узнать состояние сети и подписаться на его изменения.- Перехватчик повторов в OkHttp: повтор на уровне клиента вместо ручного кода в репозитории.
Resultиз стандартной библиотеки Kotlin и почему для слоёв часто удобнее свой тип.- Глобальная обработка
401: перехватчик, который обновляет токен и повторяет запрос сам. WorkManager— отложенная отправка, когда действие нужно выполнить, как только сеть появится.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Обработка ошибок в корутинах — как исключения ведут себя внутри корутин и почему отмена особенная.
- OkHttp: таймауты и повторы — настройки клиента и типовые рецепты.
- Состояние сети в Android — как узнать про соединение и почему это не гарантия.
- Слой данных: обработка ошибок — рекомендация Google по возврату результата вместо исключений.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.