JavaRush /Курсы /Swift SELF /UX ошибок и канонические exit codes

UX ошибок и канонические exit codes

Swift SELF
67 уровень , 4 лекция
Открыта

1. Ошибка в CLI: три результата

Когда вы пишете CLI, легко попасть в ловушку «ну упало и упало»: вывел print(error) и пошёл дальше. Но CLI — это не только человек, который смотрит на экран. 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 предназначен для «нормального результата», который можно пайпить дальше (|). 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 "Непредвиденная ошибка. Подробности в логах."
}

Здесь мы используем computed properties userMessage на самих ошибках, добавленные через extension. Это удобно: текст ближе к типу ошибки, а не размазан по всему CLI.

И да, фраза «Непредвиденная ошибка» — это честно. Когда вы не знаете, что произошло, лучше признаться, чем написать «всё сломалось навсегда».

Где именно происходит «перевод» ошибки в exit code

Полезно один раз увидеть глазами, что мы делаем. Внутри сценария ошибки «живут» как типы (NetworkError, StorageError). На границе CLI мы делаем перевод в внешний контракт.

flowchart TD
    A[Scenario: fetch / fetch-many] -->|throws Error| B[CLI boundary: 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 SELF, 67 уровень, 4 лекция
Недоступна
Таблица exit-кодов
Таблица exit-кодов
1
Задача
Swift SELF, 67 уровень, 4 лекция
Недоступна
Два потока вывода
Два потока вывода
1
Задача
Swift SELF, 67 уровень, 4 лекция
Недоступна
Ошибка в код и текст
Ошибка в код и текст
1
Задача
Swift SELF, 67 уровень, 4 лекция
Недоступна
Единая обработка
Единая обработка
1
Опрос
Таймауты Swift, 67 уровень, 4 лекция
Недоступен
Таймауты Swift
Таймауты, кэш, лимитер, cancellation
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ