1. Базовая модель устойчивости в сети
Если вы раньше писали программы, которые считают числа или сортируют списки, мир был относительно честным: если код компилируется и не делит на ноль — он обычно работает. С сетью мир внезапно становится похож на доставку пиццы: вы заказали, но это не гарантирует, что она доедет, и тем более вовремя. Наша задача — научиться писать код, который реагирует на такие ситуации предсказуемо и спокойно.
Важная мысль для дня: ошибка сети — это не “редкий крайний случай”, а один из обычных сценариев. Поэтому обработка ошибок — не “потом добавим”, а часть дизайна функции: что она возвращает, что печатает (или не печатает), и какие решения принимает вызывающий код.
Два разных типа проблем: non‑2xx и исключения
Когда вы делаете HTTP‑запрос, неприятности бывают двух принципиально разных видов.
Первый вид — сервер ответил, но ответ “неуспешный”: статус 400/404/500 и т.д. Это всё равно нормальный HttpResponse. Он пришёл. У него есть status, часто есть тело, иногда даже JSON (особенно если сервер любит структурированные ошибки).
Второй вид — ответа не было как факта: таймаут, не удалось подключиться, оборвалось соединение. В этом случае вы обычно получаете исключение, и до HttpResponse ваш код даже не добирается. В Kotlin такие ситуации как раз и обрабатываются через try/catch, причём try/catch может быть выражением и возвращать значение — это удобно для компактного “успех/ошибка” кода.
Чтобы не путаться, держите мини‑таблицу в голове:
| Ситуация | Что случилось “по факту” | Как обрабатывать |
|---|---|---|
|
Сервер ответил, но ресурс не найден | Ветвление по response.status (non‑2xx) |
|
Сервер ответил, но у него проблема | Ветвление по response.status (часто можно ретраить) |
| Таймаут | Ответа нет | try/catch (исключение) |
| Нет сети / DNS | Ответа нет | try/catch (исключение) |
Короткий “скелет” правильного мышления выглядит так:
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
suspend fun call(client: HttpClient): HttpResponse {
return client.get("https://api.example.com/ping")
}
Если call(...) вернул HttpResponse — значит сеть “как факт” сработала, и дальше вы решаете по статусу. Если не вернул — значит вы в мире исключений.
Паттерн обработки: “статус → ветка → тело”
На практике новички часто попадают в ловушку: “ну раз я получил текст/JSON, значит всё хорошо”. С HTTP так нельзя: тело может быть сообщением об ошибке, HTML‑страницей 404, JSON‑ошибкой, чем угодно. Поэтому стиль “сначала статус, потом смысл” — это ваша страховка от странных багов.
Сделаем маленький помощник, чтобы читать код было проще:
import io.ktor.http.*
fun HttpStatusCode.isSuccessful(): Boolean {
return value in 200..299
}
Теперь проверка превращается в читаемую фразу:
import io.ktor.client.statement.*
suspend fun printStatusAndBody(response: HttpResponse) {
val bodyText = response.bodyAsText()
println("status=${response.status.value}") // status=200 (пример)
println("body=$bodyText")
}
Обратите внимание на тонкий момент: bodyAsText() — это “прочитать тело”. Обычно тело читают один раз. Поэтому лучше строить код так, чтобы в каждой ветке вы читали тело один раз и дальше работали с полученным значением.
Таймауты: лучше честное “не успели”, чем ждать вечность
Без таймаута сетевой запрос может ждать очень долго. Иногда это выглядит как “программа зависла”, хотя на самом деле она терпеливо ждёт. Пользователь (или вы) в этот момент думает: “ну всё, Kotlin сломался”, а Kotlin ни при чём — он просто слишком воспитанный.
Таймаут — это контракт: сколько мы готовы ждать. Если не уложились — считаем это ошибкой выполнения запроса (то есть исключением) и обрабатываем в catch.
В Ktor это обычно делается установкой плагина HttpTimeout на клиенте. Важно: точные поля таймаутов зависят от версии Ktor, но общая идея одна — ограничить ожидание.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
fun buildClientWithTimeout(): HttpClient {
return HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 3_000
}
}
}
Почему это относится к “устойчивости”? Потому что с таймаутом у вас появляется предсказуемое поведение: через 3_000 миллисекунд вы либо получили ответ, либо переходите в ветку ошибки и можете показать понятное сообщение, сделать ретрай, предложить пользователю повторить позже и так далее.
2. Результат как значение и ограниченные ретраи
Возвращаем результат как значение: sealed вместо println в catch
Когда вы только начинаете, очень хочется сделать так:
try {
// ...
} catch (e: Exception) {
println("Ошибка: ${e.message}")
}
Это нормально на первых шагах, но очень быстро превращает код в кашу: функция вроде бы “что-то делает”, но результат непонятен, вызывающий код не знает, получилось или нет, а сообщения печатаются из разных мест.
Гораздо спокойнее — возвращать результат как явное значение, например через sealed class. А потом уже в одном месте решать, что печатать.
Сделаем минимальный результат для сетевого вызова:
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data class HttpError(val status: Int, val body: String) : ApiResult<Nothing>()
data class NetworkError(val message: String) : ApiResult<Nothing>()
}
Почему здесь удобно sealed? Потому что потом мы сможем обработать результат через when и получить исчерпывающие ветки (компилятор будет помогать не забыть какой-то случай). У when как выражения есть правило: если вы используете его как выражение (присваиваете результат), оно должно покрывать все варианты, иначе компилятор ругнётся.
“Безопасный GET”: non‑2xx отдельно, исключения отдельно
Соберём всё в аккуратную функцию. Для примера продолжим стиль “пользователь по id”, который вы уже видели на прошлой лекции про JSON‑интеграцию.
Модель:
import kotlinx.serialization.Serializable
@Serializable
data class UserResponse(
val id: Int,
val name: String
)
А теперь — функция fetchUserSafe. Здесь ключевой момент: non‑2xx — это не catch. Это обычная ветка по response.status.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
suspend fun fetchUserSafe(client: HttpClient, id: Int): ApiResult<UserResponse> {
return try {
val response: HttpResponse = client.get("https://api.example.com/users/$id")
if (response.status.isSuccessful()) {
val user: UserResponse = response.body()
ApiResult.Ok(user)
} else {
val errorText = response.bodyAsText()
ApiResult.HttpError(status = response.status.value, body = errorText)
}
} catch (e: Exception) {
ApiResult.NetworkError(message = e.message ?: "network error")
}
}
Заметьте, как ровно разделились два мира: “ответ пришёл, но плохой” и “ответа не было”. Именно такую границу мы и хотим.
И ещё одна важная мысль: try/catch в Kotlin можно воспринимать как выражение, которое возвращает значение. Это прямой язык‑инструмент для “вернуть Ok или вернуть Error”.
Ограниченный ретрай: повторяем запрос, но не превращаемся в дятла
Ретрай (повтор) полезен, когда проблема временная. Но если сделать ретрай “вечно”, ваше приложение станет вечным двигателем… только он будет расходовать интернет и нервы.
Здесь есть два здравых ограничения: ограничение по числу попыток и небольшая пауза между попытками.
Сделаем простую функцию retry, которая запускает блок несколько раз. Я специально держу её короткой, чтобы не утонуть в деталях.
import kotlinx.coroutines.delay
suspend fun <T> retry(
attempts: Int,
delayMillis: Long,
block: suspend () -> T
): T {
var lastError: Exception? = null
repeat(attempts) {
try {
return block()
} catch (e: Exception) {
lastError = e
delay(delayMillis)
}
}
throw lastError ?: IllegalStateException("Unknown error")
}
Да, эта версия ловит “все исключения” — потому что мы сейчас учимся механике. В реальном проекте вы обычно не хотите ретраить всё подряд (например, ошибку парсинга JSON ретраить бессмысленно). Но базовый паттерн вам нужен именно такой: ограниченно и с паузой.
Схема того, что происходит, выглядит почти как блок‑схема “попробуй ещё раз, но не бесконечно”:
flowchart TD
A[Попытка #1] -->|успех| OK[Вернули результат]
A -->|исключение| D[delay]
D --> B[Попытка #2]
B -->|успех| OK
B -->|исключение| D2[delay]
D2 --> C[Попытка #N]
C -->|успех| OK
C -->|исключение| FAIL[Бросаем последнюю ошибку]
Теперь самое интересное: что именно мы ретраим? Ответ: чаще всего ретраить имеет смысл только “сетевые сбои” (исключения) и иногда 5xx (ошибки сервера, потому что сервер мог “моргнуть”). А вот 4xx ретраить обычно бессмысленно: если вы отправили неверные данные, повторить то же самое — не станет внезапно верным.
Мы пока не строим сложную классификацию (это отдельная большая тема), но простое правило можете применять уже сейчас: исключения — кандидат на ретрай, HttpError(4xx) — обычно нет.
Ретрай поверх fetchUserSafe: как аккуратно “склеить” два инструмента
Чтобы не размазывать ретрай по всему коду, проще обернуть именно “вызов сети”. Например, так:
suspend fun fetchUserWithRetry(client: HttpClient, id: Int): ApiResult<UserResponse> {
return try {
retry(attempts = 3, delayMillis = 300) {
val r = fetchUserSafe(client, id)
// Ретраим только сетевые ошибки, а не HTTP-ответы
if (r is ApiResult.NetworkError) {
throw IllegalStateException(r.message)
}
r
}
} catch (e: Exception) {
ApiResult.NetworkError(message = e.message ?: "network error")
}
}
Код выглядит немного “хитро”, но идея простая: если результат — NetworkError, мы временно превращаем его в исключение, чтобы сработала retry. Всё остальное возвращаем как есть.
Да, можно сделать красивее (например, чтобы retry работал с ApiResult напрямую), но сейчас нам важнее понять принцип и не утонуть в инженерном перфекционизме.
Как показать результат пользователю: одно место, один стиль
Теперь мы можем в main сделать максимально “чистую” логику: получить ApiResult и превратить его в текст.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildClientWithTimeout()
try {
val result = fetchUserWithRetry(client, id = 42)
val message = when (result) {
is ApiResult.Ok -> "User: ${result.value.name}" // User: Ada (пример)
is ApiResult.HttpError -> "HTTP ${result.status}: ${result.body}"
is ApiResult.NetworkError -> "Network error: ${result.message}"
}
println(message)
} finally {
client.close()
}
}
Почему finally — это хорошо? Потому что ресурсы должны закрываться гарантированно, даже если где-то по дороге случилась ошибка. finally как раз и существует для “выполнится в любом случае”.
3. Типичные ошибки при обработке сетевых ошибок и ретраев
Ошибка №1: считать, что non‑2xx “поймается в catch”.
Это одна из самых частых логических путаниц. Если сервер вернул 404 или 500, у вас есть HttpResponse, и это не исключение само по себе. Если вы обрабатываете такие случаи только через catch, вы либо вообще их не обработаете, либо начнёте костылить, теряя смысл статуса. Правильный путь — ветвиться по response.status, а catch оставлять для ситуаций, когда ответа нет как факта.
Ошибка №2: “если пришло тело, значит успех”.
Тело может быть чем угодно: JSON с ошибкой, HTML, пустая строка, сообщение “not found”. Единственная надёжная точка входа — статус. Сначала статус, потом смысл. Особенно если вы делаете response.body<T>(): на ошибочном статусе формат тела может вообще быть другим, и вы получите исключение парсинга там, где на самом деле нужно было просто показать HTTP 404.
Ошибка №3: отсутствие таймаута и ощущение “программа зависла”.
Без таймаута вы не контролируете время ожидания. Это не только UX‑проблема (“ничего не происходит”), но и техническая: зависшие запросы держат ресурсы, создают очередь задач и ухудшают работу приложения. Таймаут делает поведение предсказуемым: либо успели, либо обрабатываем ошибку.
Ошибка №4: бесконечный ретрай (while(true) + “авось заработает”).
Такой код выглядит как оптимизм, но ведёт себя как мини‑атака на сервер (и на батарейку ноутбука). Любой ретрай должен быть ограничен по числу попыток и иметь паузу. Иначе в случае реальной проблемы вы не “поможете”, а просто создадите шум.
Ошибка №5: ретраить всё подряд, включая 4xx.
Если вы отправили неверный запрос, повторить его ещё 10 раз — это не упорство, это упрямство. 4xx обычно означает проблему в запросе (данные/доступ/формат), а не временную нестабильность сети. В базовой версии приложения ретраим только сетевые исключения; HttpError возвращаем сразу.
Ошибка №6: печатать ошибки внутри сетевых функций.
Когда fetchUserSafe() внутри себя делает println(...), вызывающий код теряет контроль: он не может выбрать стиль сообщения, язык, формат, и не может, например, показать ошибку пользователю иначе. Гораздо устойчивее возвращать ApiResult и решать “что печатать” в одном месте, используя when с исчерпывающими ветками.
Ошибка №7: забыть про finally и не закрывать ресурсы.
Даже в маленьких примерах важно прививать привычку: ресурсы закрываются гарантированно. finally выполняется независимо от того, была ошибка или нет, и идеально подходит для client.close().
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ