1. Чому ми взагалі розкладаємо мережеві помилки за шарами
Якщо ви колись бачили повідомлення «Інтернет зламався», а потім виявлялося, що сервер просто повернув 404, ви вже зіткнулися з проблемою: слово «помилка» надто загальне. У мережевому коді важливо розрізняти причини невдачі: запит міг не виконатися, відповідь могла виявитися не HTTP, сервер міг повернути 500, а JSON — не відповідати очікуваній формі. І поки ми не розкладемо все це по поличках, наш код або без кінця друкуватиме print("Щось пішло не так"), або намагатиметься «полагодити» реальність оператором ! (спойлер: реальність не лагодиться).
Є ще одна практична причина: інтерфейс URLSession повертає три значення, і всі вони опціональні. Їх дуже легко переплутати. Саме тому Result так добре підходить для мережевих обробників завершення: він змушує нас обрати — або успіх, або помилка, третього не дано.
2. Карта перевірки результату URLSession: чотири «ворота»
Щоб перестати плутатися, корисно уявити обробку відповіді як проходження чотирьох «воріт». Це схоже на охорону в офісі: спочатку перевіряємо, чи взагалі людина дісталася будівлі, потім — чи вона співробітник, далі — чи має перепустку, і лише тоді — чи потрапила в потрібний кабінет, а не в їдальню. Хоча їдальня теж важлива, але це вже інше завдання.
Перші «ворота» — transport: чи дійшов запит до сервера і чи повернулася бодай якась відповідь. Саме тут проявляються проблеми рівня «немає мережі», «DNS не резолвиться», «сертифікат», «таймаут» — у термінах URLSession це error != nil.
Другі «ворота» — invalid response: навіть якщо error == nil, URLSession повертає URLResponse, який не зобов’язаний бути HTTPURLResponse. Для HTTP-клієнта це критично: без HTTPURLResponse у нас немає statusCode, а отже ми не можемо чесно сказати, це успіх чи ні.
Треті «ворота» — http status: відповідь могла прийти, але це ще не успіх. Статус-код — частина контракту. Умовно, 404 — це «сервер живий, але ви попросили те, чого немає», а 500 — «сервер живий, але йому зле».
Четверті «ворота» — decoding: статус успішний, тіло прийшло, але дані можуть не відповідати очікуваній моделі Decodable. Це не транспортна проблема і не HTTP-проблема — це проблема формату даних або вашого очікування щодо цього формату.
Наочно це зручно тримати в голові так:
flowchart TD
A[URLSession завершив запит] --> B{error != nil?}
B -->|так| E[".transport(error)"]
B -->|ні| C{response — HTTPURLResponse?}
C -->|ні| F[.invalidResponse]
C -->|так| D{statusCode у 200...299?}
D -->|ні| G[".httpStatus(code, body)"]
D -->|так| H{декодування в модель вдалося?}
H -->|ні| I[".decoding(error)"]
H -->|так| J[".success(model/data)"]
І ще одна маленька таблиця, щоб «шари» не перемішувалися в голові:
| Шар проблеми | Як виявляємо | Що це означає простими словами | Кейс NetworkError |
|---|---|---|---|
| Transport | |
запит не виконався | |
| Response | |
«відповідь» не HTTP, статус-коду немає | |
| HTTP status | |
сервер відповів, але це ще не успіх | |
| Decoding | |
формат даних не збігся з моделлю | |
3. Канонічний NetworkError: дизайн enum
Коли команда починає писати мережевий код без спільної моделі помилок, дуже швидко виникає зоопарк: RequestError, APIError, NetworkingFailure, HttpError, ServerError, DecodeError, і десь ще NetworkError2 — бо «перший був невдалий». Далі починаються проблеми: різні шари застосунку не можуть домовитися, що вважати помилкою і як її показувати користувачу.
Тому ми фіксуємо канонічний NetworkError — єдиний тип помилок мережевого шару в межах курсу. Він не мусить покривати абсолютно все, що існує в інтернеті, але має покривати базовий життєвий цикл запиту: transport → response → status → decoding.
Мінімальний, але практичний варіант виглядає так:
import Foundation
enum NetworkError: Error {
case transport(Error)
case invalidResponse
case httpStatus(code: Int, body: Data?)
case decoding(Error)
}
Тут є два важливі дизайнерські рішення. По-перше, ми не втрачаємо вихідний Error: для .transport і .decoding він зберігається, щоб можна було логувати деталі, не перетворюючи будь-яку проблему на «щось пішло не так». По-друге, у випадку httpStatus ми зберігаємо body як Data?: іноді сервер надсилає корисне повідомлення про помилку, і його дуже приємно побачити хоча б у логах.
Щоб NetworkError було простіше друкувати й налагоджувати, додамо акуратний опис. Це не «обов’язкова магія», а просто зручність:
import Foundation
extension NetworkError: CustomStringConvertible {
var description: String {
switch self {
case .transport(let error): return "Транспортна помилка: \(error)"
case .invalidResponse: return "Недійсна відповідь (не HTTP)"
case .httpStatus(let code, _): return "Помилка HTTP-статусу: \(code)"
case .decoding(let error): return "Помилка декодування: \(error)"
}
}
}
4. Адаптер: URLSession → Result
Найпрактичніша частина лекції — написати адаптер, який бере незручний completion handler URLSession і перетворює його на зручний Result. Це той випадок, коли ви один раз акуратно фіксуєте порядок перевірок, а потім увесь проєкт починає жити значно спокійніше.
Ідея така: ми робимо функцію fetchData, яка приймає URLRequest і completion у форматі (Result<Data, NetworkError>) -> Void. Completion позначаємо @escaping, бо його викличуть пізніше, після того як fetchData вже поверне керування. А далі — суворо дотримуємося порядку перевірок, який закріпили за «чотирма воротами».
import Foundation
func fetchData(
request: URLRequest,
session: URLSession = .shared,
completion: @escaping (Result<Data, NetworkError>) -> Void
) {
session.dataTask(with: request) { data, response, error in
if let error { completion(.failure(.transport(error))); return }
guard let http = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse)); return
}
guard (200...299).contains(http.statusCode) else {
completion(.failure(.httpStatus(code: http.statusCode, body: data))); return
}
completion(.success(data ?? Data()))
}.resume()
}
Тут захована невелика дисципліна, яка економить години налагодження: після кожного completion(...) ми робимо return. Якщо цього не зробити, можна випадково викликати completion двічі, а подвійний виклик completion — це класична «невидима» помилка, яка ламає код, що викликає.
Шар httpStatus: «відповідь прийшла» не дорівнює «успіх»
Дуже хочеться думати так: «Ну data ж прийшла… отже, усе гаразд». Але HTTP — це система, у якій сервер цілком може надіслати тіло відповіді і при 404, і при 500. Іноді це HTML-сторінка помилки, іноді JSON виду { "error": "invalid token" }, іноді взагалі порожньо. І якщо ви не перевірили статус-код, ви можете спробувати декодувати «сторінку 404» як модель BookDTO і отримати decoding error, який виглядатиме так, ніби зламався JSON. Хоча насправді зламалася логіка запиту.
Тому статус-код — це окрема перевірка і окремий кейс помилки. Щоб код читався як речення, дуже корисно мати маленьку функцію:
func isHTTPSuccess(_ statusCode: Int) -> Bool {
(200...299).contains(statusCode)
}
print(isHTTPSuccess(204)) // true
print(isHTTPSuccess(404)) // false
Коли статус неуспішний, ми зберігаємо body як Data?. Але Data — це байти, а людині хочеться бодай якоїсь попередньої версії. Зробімо невеличкий helper, який намагається показати початок тіла як UTF-8:
import Foundation
func bodyPreview(_ data: Data?) -> String {
guard let data, let text = String(data: data, encoding: .utf8) else {
return "<немає тіла UTF-8>"
}
return String(text.prefix(120))
}
Зверніть увагу: ми не гарантуємо, що тіло — це текст, і не примушуємо його силоміць ставати String(data:..., encoding: .utf8)!. Якщо не вийшло — значить не вийшло. Це нормальна ситуація.
Шар decoding: із Data в модель
Після того як транспорт успішний, HTTP-відповідь валідна, а статус-код успішний, ми можемо нарешті зайнятися тим, заради чого все й починалося: перетворити байти на модель.
Важливо пам’ятати: декодування — це окремий шар невдачі. Якщо ви змішаєте decoding із transport/status, то потім не зможете зрозуміти, що саме зламалося: мережа, сервер чи ваша модель Decodable.
Зробімо обгортку fetchDecodable, яка використовує fetchData, а далі намагається викликати JSONDecoder().decode(...):
import Foundation
func fetchDecodable<T: Decodable>(
_ type: T.Type,
request: URLRequest,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
fetchData(request: request) { result in
switch result {
case .success(let data):
do {
let value = try JSONDecoder().decode(T.self, from: data)
completion(.success(value))
} catch {
completion(.failure(.decoding(error)))
}
case .failure(let error):
completion(.failure(error))
}
}
}
Зверніть увагу, що ми не перетворюємо .decoding(error) на .transport(error) і не вдаємо, що «це все одне й те саме». Це різні причини, різні дії для виправлення і різні повідомлення користувачу.
Щоб приклад був живим, опишімо якусь просту модель. У контексті LibraryCLI це може бути «відповідь ping» або «книга». Неважливо, який саме сервер, зараз нам важлива саме форма:
import Foundation
struct PingResponse: Decodable {
let message: String
}
І приклад виклику:
import Foundation
func demoPing() {
guard let url = URL(string: "https://example.com/api/ping") else {
print("Некоректний URL"); return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
fetchDecodable(PingResponse.self, request: request) { result in
print(result) // success(...) або failure(...)
}
}
Так, цей код асинхронний, і completion прийде пізніше. Зараз ми не розв’язуємо задачу «як красиво дочекатися відповіді в CLI» — ми розв’язуємо задачу «як правильно класифікувати результат, коли він надійде».
5. Вбудовуємо це в LibraryCLI
Коли ви розробляєте навчальний застосунок, дуже легко зробити або «все в одному файлі на 800 рядків», або навпаки — «10 протоколів, 12 адаптерів і маленький модуль, який нічого не робить». Нам потрібен спокійний середній варіант: щоб мережевий шар був окремим і повторно використовуваним, але без зазіхань на майбутні теми. Жодних async/await, жодних протоколів HTTPClient і моків — це буде пізніше.
Практичний крок — додати в проєкт, наприклад у таргеті Networking, два файли: NetworkError.swift і NetworkFetch.swift, де й житимуть NetworkError, fetchData і fetchDecodable. Тоді решта коду проєкту зможе говорити: «я хочу Result<Model, NetworkError>», не згадуючи про (Data?, URLResponse?, Error?).
Якщо хочеться додати зовсім невелику обгортку-обʼєкт — не обов’язкову, але зручну — можна зробити NetworkService, який зберігає URLSession:
import Foundation
struct NetworkService {
let session: URLSession
func fetchData(
request: URLRequest,
completion: @escaping (Result<Data, NetworkError>) -> Void
) {
Swift.fetchData(request: request, session: session, completion: completion)
}
}
Тут є маленький трюк із Swift.fetchData: ми явно звертаємося до глобальної функції, щоб не зациклитися на методі з тією самою назвою. Це не магія, а просто спосіб уникнути конфлікту імен.
Такий NetworkService зручно створювати із сесією .shared або з власною, якщо ви налаштовували URLSessionConfiguration на попередній лекції.
6. Типові помилки
Помилка №1: звести все до одного кейса .unknown (або .other).
На старті здається, що це «прискорює розробку»: не думаємо, чому зламалося, просто кажемо «зламалося». Але вже на другій же реальній помилці ви зрозумієте ціну такого підходу: однакова обробка для 404 і для відсутності мережі призводить до неправильних підказок користувачу та до неможливості нормально налагоджувати код. Чотири кейси NetworkError — це не розкіш, а мінімальний набір для тверезості.
Помилка №2: не перевіряти statusCode і намагатися декодувати все підряд.
Це дуже часта пастка: «якщо є data, значить можна декодувати». У підсумку HTML-сторінка помилки перетворюється на .decoding(error), і ви починаєте «лагодити JSONDecoder», хоча проблема в тому, що запит узагалі був неправильним або сервер відмовив. Перевірка 200...299 — обов’язкова сходинка перед декодуванням.
Помилка №3: робити response as! HTTPURLResponse.
Іноді здається, що «ну ми ж точно ходимо по HTTP». Але as! перетворює рідкісний крайовий випадок на гарантований креш. Правильний стиль — as? і окрема помилка .invalidResponse. Навіть якщо в реальності ви ніколи її не побачите, вона робить контракт чесним: «якщо не HTTP — ми це вміємо висловити».
Помилка №4: втрачати вихідну помилку, перетворюючи її на рядок.
Якщо ви робите .transport(String) замість .transport(Error) — або просто друкуєте error.localizedDescription і викидаєте об’єкт, — ви втрачаєте дуже багато корисної діагностики. Мережеві помилки часто містять коди, домени, вкладені причини, і все це важливо хоча б для логів. Користувачу потрібен рядок, але розробнику потрібен Error.
Помилка №5: викликати completion двічі або не викликати його взагалі.
Це той самий баг, який потім виглядає як «іноді все зависає» або «іноді UI оновлюється двічі». Зазвичай причина банальна: після completion(.failure(...)) забули return, і код пішов далі до completion(.success(...)). Дисципліна ранніх return — не занудство, а страховка від таких привидів. І так, API з completion handler передбачає, що completion викликається рівно один раз — інакше сторона, що викликає, не зможе на вас покластися.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ