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 | Смысл |
|---|---|---|---|
| Успех | |
|
Всё прошло хорошо |
| Ошибка ввода/аргументов | |
|
Пользователь ввёл команду/аргументы неверно |
| Сеть | |
|
Запрос не выполнен (transport/HTTP/decode) |
| Локальное хранилище | |
|
Не смогли прочитать/записать данные |
| Отмена | |
|
Операция отменена (cooperative cancellation) |
| Непредвиденное | |
|
Всё остальное, что мы не классифицировали |
Код:
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 должен быть один. Если категории не хватает — вы добавляете её в одно место, а не городите локальные договорённости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ