1. Знайомимося з частковим успіхом у fetch-many
Коли ви вперше пишете fetch-many, майже автоматично хочеться зробити «як завжди»: запустити запити й написати try await, а якщо щось зламалося — «нехай падає». Це підхід fail-fast: перша ж помилка перериває весь сценарій. Він чесний, але часто незручний, оскільки мережа — штука примхлива, а пакетні операції зазвичай запускають саме для того, щоб отримати максимум можливого.
Уявіть: ви попросили fetch-many 10 11 12, сервер для 11 повернув 500, а 10 і 12 віддали коректні дані. Fail-fast влаштує драму масштабу «один студент спізнився — скасовуємо весь іспит»: ви втратите те, що могли успішно отримати.
Partial success (частковий успіх) — це модель, у якій ми свідомо кажемо: «у цьому сценарії помилки по елементах не зобов’язані валити весь батч». Замість «одна помилка = кінець» ми хочемо отримати звіт такого виду:
- які id успішно отримані та готові до використання,
- які id упали й чому,
- що показуємо користувачу — коротко і зрозуміло,
- що логуємо — детально,
- який код завершення повертаємо.
Тут є ключовий поворот мислення: помилка стає даними, а не аварійною зупинкою.
Невелика таблиця для орієнтиру:
| Модель | Що відбувається під час першої помилки | Що отримує користувач | Для яких сценаріїв підходить |
|---|---|---|---|
| Fail-fast | усе переривається | «не вдалося» | критична атомарність (або все, або нічого) |
| Partial success | продовжуємо, збираємо результати | «отримано X із Y + список проблем» | батчі, мережа, «забрати максимум» |
2. Result: значення або помилка
З технічного погляду Result<Success, Failure: Error> — це стандартний спосіб у Swift зберігати «або успіх, або помилку» так, щоб обробка могла відбутися пізніше і в іншому місці. Це буквально «упакування результату в коробку», щоб не тягнути по коду do/catch на кожному кроці. Ідея такого контейнера особливо корисна, коли ви хочете виконати кілька операцій і розібратися з помилками вже потім.
У Result є два стани:
- .success(value) — всередині лежить значення,
- .failure(error) — всередині лежить помилка.
У нашому fetch-many value — це, наприклад, BookDTO або вже доменний Book, а error — будь-що з «поганого»: помилка збирання URLRequest, мережевий NetworkError, помилка декодування тощо.
І ось важлива думка лекції: у partial success ми маємо загортати в Result не лише мережу, а й сам етап збирання запиту. Інакше вийде напівправда: «мережу я враховую, а те, що я не зміг навіть зібрати URL, — це наче не помилка, а „ну буває“».
Міні-адаптер asResult
Коли в проєкті з’являються async throws функції, виникає типова проблема новачка: ви або все обгортаєте в do/catch, або починаєте ставити try? «щоб працювало». Другий варіант особливо небезпечний: try? перетворює помилку на nil, і ви втрачаєте причину, контекст і можливість нормально відзвітувати користувачу.
Нам потрібен маленький універсальний адаптер: він виконує async throws операцію та повертає Result. Помилка не зникає, а стає значенням у .failure.
import Foundation
func asResult<T>(_ operation: () async throws -> T) async -> Result<T, Error> {
do {
return .success(try await operation())
} catch {
return .failure(error)
}
}
Зверніть увагу на важливу ідею цього коду. Ми не говоримо «помилка — це кінець програми», ми говоримо «помилка — це один із варіантів результату». Для батча це ідеальна позиція: кожен елемент сам по собі або успішний, або ні.
Контекст: id має жити поруч із Result
Якщо ви зберете масив Result<Book, Error>, дуже швидко стане зрозуміло, що він недостатньо інформативний. Помилка є, а який саме id упав — незрозуміло. У батч-сценаріях це майже марно: уявіть лог «decode failed» без згадки елемента — ви дивитиметеся на нього, як на печиво без цукру: ніби їжа, але радості мало.
Тому робимо маленький контейнер «результат + контекст».
import Foundation
struct ItemResult<Value> {
let id: Int
let result: Result<Value, Error>
}
Це проста структура, але вона дисциплінує архітектуру: ми більше не втрачаємо зв’язок «помилка ↔ елемент».
3. Загортаємо крок цілком: build + fetch + mapping
Тепер ми робимо головний правильний крок: не «загортаємо лише api.fetch», а загортаємо все, що може зламатися на шляху отримання одного елемента.
Нехай у нас уже є:
- BooksEndpoint.details(id:) і makeRequest(baseURL:) throws -> URLRequest,
- ApiClient.fetch(_:as:) async throws -> T,
- BookDTO і Book (доменна модель),
- ініціалізатор Book(dto:).
Тоді функція «отримай один елемент як ItemResult<Book>» виглядає так:
import Foundation
func fetchOneAsResult(
id: Int,
baseURL: URL,
api: ApiClient
) async -> ItemResult<Book> {
let r: Result<Book, Error> = await asResult {
let request = try BooksEndpoint.details(id: id).makeRequest(baseURL: baseURL)
let dto = try await api.fetch(request, as: BookDTO.self)
return Book(dto: dto)
}
return ItemResult(id: id, result: r)
}
Тут «магія» насправді дуже практична:
- якщо makeRequest упав через некоректний baseURL або неможливість зібрати URLComponents, це стане .failure(error),
- якщо мережа впала або прийшов поганий HTTP-статус (ваш NetworkError), це теж .failure(error),
- якщо JSON не декодується — знову .failure(error).
Ми чесно фіксуємо: будь-який збій на шляху елемента — частина partial success.
4. Паралельний запуск: async let без зайвої громіздкості
Тепер нам треба отримати кілька таких ItemResult. Тут ми запускаємо запити паралельно через async let, тому обмежуємося сценарієм «2 або 3 id». Такий варіант добре ілюструє ідею та не перетворює код на комбайн.
Важливо пам’ятати семантику async let: це створення дочірніх задач, а читання значення відбувається через await (а якщо всередині може бути throw — через try await).
Оскільки fetchOneAsResult(...) не кидає, вона завжди повертає значення, усередині якого вже є Result. Отже, нам достатньо await.
import Foundation
func fetchTwo(
ids: (Int, Int),
baseURL: URL,
api: ApiClient
) async -> [ItemResult<Book>] {
async let a = fetchOneAsResult(id: ids.0, baseURL: baseURL, api: api)
async let b = fetchOneAsResult(id: ids.1, baseURL: baseURL, api: api)
return await [a, b]
}
А якщо ми хочемо підтримати «2 або 3», зручно зробити switch за кількістю id. Це виглядає трохи ручним, зате код читається дуже прямо — корисно для новачків:
import Foundation
func fetchManyLimited(
ids: [Int],
baseURL: URL,
api: ApiClient
) async -> [ItemResult<Book>] {
if ids.count == 2 {
return await fetchTwo(ids: (ids[0], ids[1]), baseURL: baseURL, api: api)
}
async let a = fetchOneAsResult(id: ids[0], baseURL: baseURL, api: api)
async let b = fetchOneAsResult(id: ids[1], baseURL: baseURL, api: api)
async let c = fetchOneAsResult(id: ids[2], baseURL: baseURL, api: api)
return await [a, b, c]
}
Так, тут ми очікуємо, що ids.count дорівнює 2 або 3. Валідацію кількості аргументів CLI зазвичай виконують раніше — на етапі парсингу або перевірки команди. Нам зараз важливо інше: паралельна фаза повертає структурований набір результатів, а не кидає першу ж помилку.
Невелика схема процесу «для одного елемента» і «для всього батча»:
flowchart TD
subgraph One[Один id]
A[id] --> B[makeRequest]
B --> C[api.fetch DTO]
C --> D[map DTO -> Book]
D --> E["Result.success(Book)"]
B -. throws .-> F["Result.failure(Error)"]
C -. throws .-> F
D -. throws .-> F
end
flowchart TD
subgraph Batch[пакетне отримання]
X[ids] --> Y[паралельно: fetchOneAsResult]
Y --> Z[ItemResult<Book>...]
Z --> R[агрегація: успіхи/помилки]
R --> P[звіт користувачу]
end
5. Агрегація результатів: відокремлюємо «збір» від «розбору»
Коли у нас є [ItemResult<Book>], ми хочемо отримати дві речі: успішні книги та список помилок із id. Важливо, що це окрема стадія. На цьому етапі ми не виконуємо мережеві запити, не будуємо запити й не ходимо в репозиторій. Ми просто акуратно розкладаємо дані по кошиках.
import Foundation
func splitResults<T>(
_ items: [ItemResult<T>]
) -> (ok: [T], failed: [(id: Int, error: Error)]) {
var ok: [T] = []
var failed: [(Int, Error)] = []
for item in items {
switch item.result {
case .success(let value):
ok.append(value)
case .failure(let error):
failed.append((item.id, error))
}
}
return (ok, failed)
}
Тут корисно не намагатися бути занадто розумними. Так, можна написати це через map/compactMap/reduce, але новачкам часто простіше читати звичайний for і switch. Ми зараз вчимося надійній архітектурі, а не граємо в «гольф коду».
6. Звіт користувачу: успіхи та помилки
Користувачу нецікаво читати Error «як є». Йому потрібне коротке резюме. При цьому розробнику — тобто вам у майбутньому — важливо не втратити деталі: вони зазвичай потрапляють у логи. Але навіть у консольному виводі корисно мати структурований звіт.
Створімо структуру звіту:
import Foundation
struct FetchManyReport {
let successCount: Int
let totalCount: Int
let failures: [(id: Int, error: Error)]
}
І функцію, яка будує звіт із результатів:
import Foundation
func makeReport(from items: [ItemResult<Book>]) -> FetchManyReport {
let parts = splitResults(items)
return FetchManyReport(
successCount: parts.ok.count,
totalCount: items.count,
failures: parts.failed
)
}
Тепер форматування. Ми не будемо вигадувати «ідеальний UX», але зробимо нормальний читабельний вивід, де видно «скільки із скількох» і які id упали.
import Foundation
func formatReport(_ report: FetchManyReport) -> String {
var text = "Отримано \\(report.successCount) із \\(report.totalCount) книг."
if !report.failures.isEmpty {
text += "\\nПомилки:"
for f in report.failures {
text += "\\n- id=\\(f.id): \\(f.error)"
}
}
return text
}
Приклад того, що може побачити користувач:
print(formatReport(report))
// Отримано 2 із 3 книг.
// Помилки:
// - id=11: httpStatus(500, ...)
Так, тут error виводиться грубувато. В ідеалі ви використовуєте userMessage (із лекції про UX помилок) і друкуєте людині дружній текст, а сирий error надсилаєте в логер. Але сама ідея звіту — саме тут: ми не приховуємо частковий успіх і не вдаємо, що «все пропало».
Скелет сценарію fetch-many
Нам корисно побачити загальну форму сценарію: спочатку отримуємо результати паралельно, потім агрегуємо, потім друкуємо звіт. Репозиторій і save() (якщо вони є в цьому сценарії) логічніше виконувати після того, як ми зібрали успіхи, але зараз ми зосереджуємося саме на partial success і звіті.
import Foundation
func runFetchMany(
ids: [Int],
baseURL: URL,
api: ApiClient
) async {
let items = await fetchManyLimited(ids: ids, baseURL: baseURL, api: api)
let report = makeReport(from: items)
print(formatReport(report))
}
Цей «скелет» цінний тим, що в ньому немає сюрпризів: одна функція відповідає за отримання (і пакування помилок), друга — за розбір, третя — за вивід. Такий код простіше тестувати, розширювати і, що важливо, пояснювати самому собі через місяць, коли ви забудете, що саме хотіли зробити.
7. Типові помилки під час partial success та агрегації Result
Помилка №1: загортають у Result лише мережевий виклик, а збирання URLRequest залишають зовні.
Це виглядає нешкідливо: «ну makeRequest майже ніколи не падає». Але в результаті ви отримуєте дивну модель: частина помилок ламає весь батч (тому що throw стався до asResult), а частина перетворюється на .failure. Користувач побачить непередбачувану поведінку: то звіт «2/3», то раптовий загальний крах. Правильна звичка — загортати весь крок елемента цілком: build + fetch + mapping.
Помилка №2: використовують try? замість Result і втрачають причину помилки.
try? перетворює помилку на nil. У partial success це майже завжди програш: ви не можете написати «id=11 упав через HTTP 500», бо ви вже викинули інформацію. Якщо вже ви ухвалили рішення продовжувати після помилок, зберігайте їх як дані (через Result), інакше ви продовжите «у темряві».
Помилка №3: збирають помилки без контексту id.
Іноді роблять масив [Error] і друкують його. У батчі це майже марно: навіть якщо помилка хороша, вам усе одно треба розуміти, який елемент упав. Лікується просто: ItemResult(id:result:) або хоча б (id, Result).
Помилка №4: змішують отримання, агрегацію, вивід і оновлення репозиторію в одну величезну функцію.
Такий код зазвичай виглядає як «простирадло»: всередині async let, всередині switch, всередині do/catch, всередині print, всередині repo.upsert, і все це перемішано. Перші два дні вам здається «я герой, усе зробив», а потім ви боїтеся чіпати цей файл, бо він ворушиться. Розділяйте стадії: отримання → агрегація → звіт (і окремо, якщо потрібно, запис у репозиторій).
Помилка №5: роблять partial success, але при цьому друкують користувачу лише «усе ок» або лише «усе погано».
Якщо вже ви вибрали модель «частковий успіх», покажіть це явно: скільки вдалося, скільки ні, і які id проблемні. Це особливо важливо в CLI, де користувач часто запускає команди зі скриптів і очікує передбачуваного результату.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ