JavaRush /Курси /Kotlin SELF /Помилки та стійкість: non‑2xx vs винятки, таймаути

Помилки та стійкість: non‑2xx vs винятки, таймаути

Kotlin SELF
Рівень 58 , Лекція 3
Відкрита

1. Базова модель стійкості в мережі

Якщо ви раніше писали програми, які рахують числа або сортують списки, світ був відносно чесним: якщо код компілюється й не ділить на нуль, то зазвичай працює. Із мережею все раптово стає схожим на доставку піци: ви замовили — але це не гарантує, що вона доїде, і тим паче вчасно. Наше завдання — навчитися писати код, який реагує на такі ситуації передбачувано й спокійно.

Важлива думка на сьогодні: мережева помилка — це не «рідкісний крайній випадок», а один зі звичних сценаріїв. Тому обробка помилок — не «потім додамо», а частина дизайну функції: що вона повертає, що друкує (або не друкує) і які рішення ухвалює код, що її викликає.

Два різні типи проблем: non‑2xx і винятки

Коли ви робите HTTP‑запит, неприємності бувають двох принципово різних видів.

Перший вид — сервер відповів, але відповідь «неуспішна»: статус 400/404/500 тощо. Це все одно нормальний HttpResponse. Він прийшов. У нього є status, часто є тіло, інколи навіть JSON (особливо якщо сервер любить структуровані помилки).

Другий вид — відповіді не було як такої: таймаут, не вдалося підʼєднатися, обірвалося зʼєднання. У такому випадку ви зазвичай отримуєте виняток, і до HttpResponse ваш код навіть не доходить. У Kotlin такі ситуації якраз і обробляють через try/catch, причому try/catch може бути виразом і повертати значення — це зручно для компактного коду «успіх/помилка».

Щоб не плутатися, тримайте в голові мінітаблицю:

Ситуація Що сталося насправді Як обробляти
response.status = 404
Сервер відповів, але ресурс не знайдено Розгалуження за response.status (non‑2xx)
response.status = 500
Сервер відповів, але на сервері сталася помилка Розгалуження за 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().

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ