JavaRush /Курси /Swift SELF /UX помилок і канонічні exit codes

UX помилок і канонічні exit codes

Swift SELF
Рівень 67 , Лекція 4
Відкрита

1. Помилка в CLI: три результати

Коли ви пишете CLI, легко потрапити в пастку «ну впало і впало»: вивели print(error) — і пішли далі. Але CLI — це не лише людина, яка дивиться на екран. Його часто запускають скрипти, CI та інші програми. Тому будь-яка помилка має одночасно бути зрозумілою людині, придатною для діагностики розробником і формалізованою для машини. Інакше ваш LibraryCLI перетворюється на ворожку: «іноді працює, іноді ні, а причини не питайте».

У реальному CLI в помилки є три «виходи» назовні. Перший — коротке повідомлення користувачу, без внутрішньої кухні. Другий — подробиці в лог, щоб за тиждень ви самі собі сказали «дякую». Третій — код завершення процесу (exit code), який можна перевірити в shell і використовувати в автоматизації.

Щоб не винаходити велосипед на кожному catch, сьогодні ми фіксуємо суворий контракт: єдиний ExitCode, єдині правила і жодних «ой, тут повернемо 1, бо так заведено».

2. Exit code як контракт, а не «ще одне повідомлення»

Exit code — це маленьке число, яке процес повертає після завершення. У командному рядку воно живе окремим життям: користувач може взагалі не дивитися на текст, а скрипт легко перевірить код. Наприклад, у bash після запуску команди можна подивитися $?, а в CI умовно сказати: «якщо код не 0 — збирання червоне». Тобто exit code — це контракт між вашим CLI і зовнішнім світом.

Важливо не плутати exit code з текстом помилки. Текст — для людини; він може змінюватися і змінюватиметься, коли ви покращуєте UX. Exit code — для автоматизації; він має бути стабільним роками. Якщо ви сьогодні повертаєте 3 на мережеву помилку, а завтра «випадково» починаєте повертати 7, чийсь скрипт почне поводитися дивно. І найприкріше: винними будете ви, хоча ви «просто змінили число».

Звідси правило: exit codes ми не вигадуємо на ходу. Ми один раз фіксуємо таблицю кодів і далі користуємося нею без альтернатив і «локальних винятків».

3. Єдиний ExitCode і коректне завершення процесу

Канонічна таблиця кодів

Нам потрібна мінімальна, але корисна таблиця кодів. Ми не хочемо 50 різних кодів — це швидко перетворюється на бюрократію. Але й «1 на все» теж не хочемо: тоді автоматизація не відрізнить мережеву проблему від битого файла на диску.

Нижче — наш канонічний ExitCode. Ми обираємо Int32, тому що системний exit() зазвичай приймає саме такий тип, і так не треба пам’ятати це щоразу.

Категорія завершення ExitCode rawValue Зміст
Успіх
.ok
0
Усе минуло добре
Помилка введення/аргументів
.invalidInput
2
Користувач увів команду або аргументи неправильно
Мережа
.networkFailure
3
Запит не виконано (transport/HTTP/decode)
Локальне сховище
.storageFailure
5
Не вдалося прочитати або записати дані
Скасування
.cancelled
6
Операцію скасовано (cooperative cancellation)
Неочікуване
.unknownFailure
10
Усе інше, що ми не класифікували

Код:

enum ExitCode: Int32 {
    case ok = 0

    case invalidInput = 2
    case networkFailure = 3
    case storageFailure = 5
    case cancelled = 6

    case unknownFailure = 10
}

Зверніть увагу: числа не «особливі», окрім нуля. Вони просто стабільні. Нам важливе не значення, а дисципліна: домовилися один раз — і більше не сперечаємося із собою в майбутньому.

Де оголошувати ExitCode і як «віддати» його назовні

З exit code є тонкість: усередині застосунку ви можете хоч сто разів написати return .networkFailure, але поки процес реально не завершився з цим кодом, зовнішній світ нічого не дізнається.

Зазвичай структура CLI виглядає так: десь є функція, яка запускає сценарій і повертає ExitCode, а точка входу (main) перетворює його на реальний код завершення процесу. У Swift це може бути top-level код або @main. Важливо розуміти, що async main у Swift — це окрема задача, і завершення цієї задачі завершує застосунок.

Скелет може бути таким:

import Foundation

@main
struct LibraryCLI {
    static func main() async {
        let code: ExitCode = await runCLI()
        terminate(code)
    }
}

А terminate ми винесемо в окрему маленьку функцію, щоб не розмазувати умовні імпорти й деталі по всьому проєкту. У цій лекції ми не заглиблюємося в платформені відмінності, тому покажемо ідею «як це влаштовано», а точну реалізацію ви підлаштуєте під свій таргет.

// псевдокод/схема (залежить від платформи: Darwin/Glibc)
func terminate(_ code: ExitCode) -> Never {
    exit(code.rawValue)
}

Чому я виношу це в окрему функцію? Тому що тоді весь інший код працює з красивим ExitCode, а «брудна» частина з exit(...) живе в одному місці й не лізе в бізнес-логіку.

4. Повідомлення користувачу і виведення в STDERR

Коротке повідомлення користувачу без «Error Domain = …»

Тепер про UX. Якщо ви виводите користувачу String(describing: error), то майже гарантовано показуєте те, що зручно розробнику, а не людині. Там будуть «domain», «code», «decoding failed», «keyNotFound» та інші заклинання, які лякають новачків і не допомагають ухвалити рішення.

Нормальне користувацьке повідомлення в CLI має відповідати на два запитання: «що сталося?» і «що мені робити далі?». І при цьому воно має залишатися коротким. Деталі — у логах.

Тому ми вводимо звичку: у помилок зʼявляється «вітрина» — userMessage. Робити це краще через extension до наявних типів помилок, щоб не створювати другу версію помилок лише заради тексту.

Наприклад, для канонічного NetworkError (transport/invalidResponse/httpStatus/decoding) це може виглядати так:

// псевдокод/схема (NetworkError визначено в мережевому шарі)
extension NetworkError {
    var userMessage: String {
        switch self {
        case .transport:
            return "Не вдалося виконати запит: проблема з мережею."
        case .invalidResponse:
            return "Сервер повернув неочікувану відповідь."
        case .httpStatus(let code, _):
            return "Сервер повернув помилку (HTTP \(code))."
        case .decoding:
            return "Відповідь сервера має неочікуваний формат."
        }
    }
}

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

STDERR vs STDOUT

У CLI є маленька, але важлива UX-деталь: успішний вивід команди та повідомлення про помилку — це різні канали. STDOUT призначений для «нормального результату», який можна передати далі через pipe (|). STDERR — для помилок. Тоді людина може виконати LibraryCLI fetch 42 > out.txt і не отримати у файлі «Не вдалося виконати запит…».

Ми зробимо крихітний helper. Використовуємо FileHandle.standardError, бо це зрозуміло і не потребує платформених імпортів.

import Foundation

func printToStderr(_ message: String) {
    let line = message + "\n"
    FileHandle.standardError.write(Data(line.utf8))
}

Якщо ви колись побачите, як ваш CLI виводить «помилка» в STDOUT і ламає чийсь пайплайн, ви почнете цінувати цей helper як родинну реліквію.

5. Логи: деталі й обов’язковий контекст

Лог — це місце, де можна і потрібно писати технічні деталі. Але навіть у логах є часта помилка: рядок на кшталт «failed» або «network error». За день ви відкриєте лог і зрозумієте майже нічого.

У конкурентних і пакетних сценаріях, а fetch-many у нас скоро зʼявиться, рядки лога можуть перемішуватися. Тому кожен рядок лога має бути самодостатнім: містити команду, фазу і, за потреби, id. Інакше ви побачите десять «failed» і влаштуєте собі вечір настільної гри «Вгадай, хто впав».

Зробимо маленький форматер контексту:

func logContext(command: String, id: Int?, phase: String) -> String {
    if let id {
        return "cmd=\(command) id=\(id) phase=\(phase)"
    }
    return "cmd=\(command) phase=\(phase)"
}

А тепер функція, яка пише помилку в лог. Тут інтерфейс Logger я показую як ідею: у вашому проєкті він уже визначений раніше, і нам важливо його не перевизначати й не робити «альтернативний логер».

// псевдокод/схема (Logger існує в проєкті)
func logFailure(logger: Logger, context: String, error: Error) {
    logger.error("\(context) error=\(error)")
}

Ключовий момент: у error=\(error) можна залишити «сирий» Error, бо лог — це для вас, а не для користувача.

6. Мапінг помилок у ExitCode і єдина точка обробки

Мапінг: за типами й категоріями, без contains

Найболючіша і найпоширеніша помилка в CLI — мапити помилки на exit code за рядками. Наприклад: if "\(error)".contains("Network") { ... }. Це виглядає як «швидко і працює», але на практиці ламається від будь-якого рефакторингу, зміни тексту, зміни description і навіть від локалізації.

Правильний шлях — мапінг за типами або за явно виділеними категоріями. Тобто ми визначаємо, які типи помилок належать до input/network/storage/cancelled, і робимо перевірку через is або as?.

Скасування — окрема історія. У structured concurrency скасування кооперативне і зазвичай виражається через CancellationError: задача вважається скасованою, і код має сам перевіряти скасування та реагувати, часто — кидком CancellationError(). Тому скасування не повинно потрапляти в .unknownFailure: це нормальний сценарій завершення, просто «не довели до кінця».

Код мапінгу:

func exitCode(for error: Error) -> ExitCode {
    if error is CancellationError { return .cancelled }
    if error is EndpointBuildError { return .invalidInput }
    if error is CommandParseError { return .invalidInput }

    if error is NetworkError { return .networkFailure }
    if error is StorageError { return .storageFailure }

    return .unknownFailure
}

Зверніть увагу на порядок. Спочатку ми ловимо максимально загальні категорії, які легко сплутати. Наприклад, CancellationError варто перевіряти окремо і раніше, щоб не промахнутися, якщо у вас десь є обгортки.

І так: тут ми використовуємо конкретні типи (NetworkError, StorageError, EndpointBuildError, CommandParseError). Це нормально: CLI-шар якраз і є місцем, де шари зустрічаються і де ви можете перекладати «внутрішню кухню» в зовнішній контракт.

Одна точка: STDERR + лог + exit code

Тепер зберемо все разом. Хороший стиль для CLI — мати одну точку, де ви ловите помилки сценарію і перетворюєте їх на користувацьке повідомлення, лог і exit code. Тоді у вас не буде десяти різних catch у різних місцях, які поводяться по-різному.

Уявімо, що runScenario — це функція, яка виконує команду, наприклад fetch, і може кинути помилку.

// псевдокод/схема (runScenario залежить від ваших шарів і типів Command)
func runCLIOnce(
    commandText: String,
    logger: Logger
) async -> ExitCode {
    do {
        let command = try parseCommand(commandText)
        try await runScenario(command, logger: logger)
        return .ok
    } catch {
        let code = exitCode(for: error)

        let message = userMessage(for: error)
        printToStderr(message)

        logFailure(logger: logger,
                   context: "cmd=\(commandText) phase=run",
                   error: error)

        return code
    }
}

Тут важливо, що exitCode(for:) і userMessage(for:) — різні функції. Це не одне й те саме. Одна дає контракт для машини, інша — текст для людини.

userMessage(for:) без «зоопарку if-ів»

Якщо робити userMessage(for:) у лоб, він справді може перетворитися на простирадло з if error is .... Але для навчального проєкту це нормально, якщо ви тримаєте його коротким і явно групуєте за категоріями.

Ось мінімальний варіант:

func userMessage(for error: Error) -> String {
    if error is CancellationError {
        return "Операцію скасовано."
    }
    if let e = error as? NetworkError {
        return e.userMessage
    }
    if let e = error as? StorageError {
        return e.userMessage
    }
    return "Неочікувана помилка. Подробиці — у логах."
}

Тут ми використовуємо обчислювані властивості userMessage на самих помилках, додані через extension. Це зручно: текст ближче до типу помилки, а не розмазаний по всьому CLI.

І так, фраза «Неочікувана помилка» — це чесно. Коли ви не знаєте, що сталося, краще визнати це, ніж написати «усе зламалося назавжди».

Де саме відбувається «переклад» помилки в exit code

Корисно один раз побачити, що саме ми робимо. Усередині сценарію помилки «живуть» як типи (NetworkError, StorageError). На межі CLI ми перекладаємо їх у зовнішній контракт.

flowchart TD
    A[Сценарій: fetch / fetch-many] -->|throws Error| B[Межа CLI: do/catch]
    B --> C["userMessage(for:) -> STDERR"]
    B --> D["logFailure(...) -> Logger"]
    B --> E["exitCode(for:) -> ExitCode"]
    E --> F["terminate(exitCode.rawValue)"]

Ця схема — хороший тест на архітектуру. Якщо ви бачите, що Scenario сам викликає exit(3), значить шари переплутані. Якщо ApiClient друкує користувачу текст, теж переплутані. Внутрішні шари мають уміти «говорити» лише через типи і throws, а CLI — перекладати це в UX і контракт процесу.

7. Типові помилки

Помилка №1: виводити користувачу String(describing: error) як «повідомлення».
Так ви показуєте людині внутрішні деталі реалізації, які зазвичай не допомагають. Користувач не має бачити keyNotFound і «The data couldn’t be read because…». Краще дайте короткий userMessage, а подробиці залиште в логах.

Помилка №2: мапити exit codes за рядками (contains, localizedDescription).
Рядки нестабільні: вони змінюються через рефакторинг, локаль і навіть версію Swift. Exit code має залежати від категорії помилки, а категорія в Swift найкраще виражається типом. Тому мапінг робимо через is/as?, а не через «пошук підрядка».

Помилка №3: вважати CancellationError «непередбачуваною помилкою».
Скасування в structured concurrency — нормальний робочий сценарій, і воно зазвичай виражається через CancellationError. Якщо ви мапите скасування в .unknownFailure, ви псуєте UX: користувач бачить «помилка», а автоматизація думає, що все зламалося, хоча команду просто зупинили.

Помилка №4: логувати без контексту (команда/фаза/id).
Лог «failed» майже марний. У fetch-many і взагалі за кількох операцій логи перемішуються, і без cmd=... phase=... id=... ви не зрозумієте, що саме впало. Робіть кожен рядок лога самодостатнім, інакше ви самі собі в майбутньому влаштовуєте квест.

Помилка №5: робити різні ExitCode в різних місцях проєкту.
Іноді хочеться «тут поверну 12, бо в мене особливий випадок». Потім хтось додає ще більш особливий випадок, і в підсумку у вас 15 місць, де є «особливі» числа. Канонічний ExitCode має бути один. Якщо категорії не вистачає, ви додаєте її в одне місце, а не нарощуєте локальні домовленості.

1
Опитування
Тайм-аути Swift, рівень 67, лекція 4
Недоступний
Тайм-аути Swift
Тайм-аути, кеш, лімітатор, cancellation
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ