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 ?: "помилка мережі")
}
}
Зауважте, як рівно розділилися два світи: «відповідь прийшла, але погана» і «відповіді не було». Саме таку межу ми й хочемо.
І ще одна важлива думка: 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 ?: "помилка мережі")
}
}
Код виглядає трохи «хитро», але ідея проста: якщо результат — 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 -> "Користувач: ${result.value.name}" // Користувач: Ada (приклад)
is ApiResult.HttpError -> "HTTP ${result.status}: ${result.body}"
is ApiResult.NetworkError -> "Помилка мережі: ${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().
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ