1. Почему exit code — это контракт, а не деталь реализации
Представьте, что ваш CLI живёт не в вакууме, а в окружении, где его запускают bash‑скрипты, Makefile’ы, пайплайны CI и другие программы. Они не читают ваши красивые сообщения, они читают код завершения процесса. И если этот код «плавает» от команды к команде, автоматизация превращается в гадание: «то ли всё сломалось, то ли пользователь просто забыл флаг».
Exit code — это машинный «итог встречи». Текст ошибки — разговор для человека. У человека бывает настроение, локализация и желание «сделать сообщение повежливее». У exit code настроения быть не должно.
Базовый договор 0/1/2: минимально строгий и максимально полезный
Дальше будет соблазн сделать 47 разных кодов, потому что «мы же инженеры, давайте классифицируем всё». Я понимаю: у многих из нас внутри живёт маленький бухгалтер, который хочет отдельную колонку для каждого типа боли. Но базовая практика для CLI проста и удивительно рабочая:
- 0 — успех (ошибки нет).
- 2 — ошибка использования (usage): неверный ввод, неверные аргументы, не хватает обязательных флагов, неправильный формат.
- 1 — ошибка выполнения (runtime): файл не открылся, данные не прочитались, что-то «внутреннее» пошло не так.
Эта схема хороша тем, что она стабильна и достаточна почти всегда. Вы не заставляете пользователя помнить коды, вы заставляете скрипт отличать «пользователь виноват» от «система/программа виновата».
И да: это именно договор. Если одна команда возвращает 2 на «плохой ввод», а другая — 1 на «плохой ввод», вы сами себе устраиваете техдолг с процентами.
2. Как определять exit code: не по тексту, а по классу ошибки
Почему нельзя вычислять exit code по тексту ошибки
Здесь обычно происходит первая трагедия начинающего автора CLI: кто-то пишет что-то вроде:
if strings.Contains(err.Error(), "missing") {
os.Exit(2)
}
os.Exit(1)
Сначала кажется, что это работает. Но потом вы меняете текст “missing” на “required” (потому что английский подтянули), и внезапно скрипты начинают считать это internal‑ошибкой. Или вы добавили контекст через fmt.Errorf("parse args: %w", err) — и слово “missing” стало встречаться в другом месте. Или вы локализовали сообщения.
Текст — плохая опора для ветвления.
В Go принято хранить структуру рядом с ошибкой: тип ошибки, “kind”, поля контекста и возможность unwrap’а. Это ровно тот подход, который обсуждается в материалах про ошибки и errors.Is/errors.As: программная логика должна опираться на значения и типы, а не на строку.
Класс ошибки как вход для таблицы exit codes
Прежде чем строить таблицу, нужно договориться, какие вообще «классы» (kind) мы признаём. В рамках CLI‑приложения todo удобно держать небольшой фиксированный набор:
- validation — неверный ввод пользователя / неверные аргументы.
- notfound — не найден ресурс (например, задача по id не существует).
- io — проблемы чтения/записи файлов, stdin/stdout и т.п.
- internal — всё остальное, что не ожидали.
Важно: список должен быть маленьким. Чем больше классов, тем больше шансов, что разные команды начнут трактовать их по-разному — и снова начнётся дрейф.
Минимальная основа под enum:
package apperr
type Kind int
const (
KindInternal Kind = iota
KindValidation
KindNotFound
KindIO
)
4. Таблица Kind → exit code: фиксируем политику и делаем её «единой точкой правды»
Базовая таблица 0/1/2
Сейчас делаем главную вещь лекции: фиксируем таблицу, которая будет одинаковой для всех команд. Это должна быть не «таблица в голове автора», а «таблица в коде» и в документации проекта.
Таблица должна быть настолько скучной, чтобы её можно было забыть — и всё равно использовать правильно. Скука тут не недостаток, а фича: чем меньше неожиданностей, тем надёжнее CLI в автоматизации.
| Состояние / класс ошибки | Что это значит для CLI | Exit code | Почему |
|---|---|---|---|
|
команда выполнилась | |
успех |
|
пользователь неправильно использовал команду | |
это usage‑ошибка |
|
не найден ресурс/сущность | |
это не usage, но и не успех |
|
чтение/запись не сработали | |
ошибка выполнения |
|
«что-то сломалось» | |
ошибка выполнения |
Обратите внимание: в базовой политике notfound не выделяется отдельным кодом. Это сделано специально, чтобы базовый контракт не расползался. «Не нашли задачу» — не успех и не ошибка ввода (обычно аргументы корректные), значит остаётся 1.
Чуть позже мы аккуратно добавим опциональное расширение — так, чтобы базовый режим оставался неизменным.
Откуда берётся Kind в реальном коде
Чтобы таблица не была просто картинкой, нам нужно уметь достать Kind из ошибки. Типичный способ — свой тип AppError + errors.As, чтобы работало сквозь wrapping. Идея unwrap’а и цепочки причин как раз обсуждается в материалах про Go 1.13 errors: если ошибка поддерживает Unwrap(), то errors.As и errors.Is могут пройти по цепочке.
Минимальная реализация:
package apperr
type AppError struct {
Kind Kind
Op string
Err error
}
func (e *AppError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *AppError) Unwrap() error { return e.Err }
И извлечение класса:
package apperr
import "errors"
func KindOf(err error) Kind {
var ae *AppError
if errors.As(err, &ae) {
return ae.Kind
}
return KindInternal
}
Реализация ExitCode(err) как таблицы в коде
Очень хочется «по‑быстрому» написать os.Exit(2) прямо в обработчике флагов, и os.Exit(1) прямо в обработчике файла. Но это как чинить проводку в квартире: один раз скрутил — потом всё искрит в случайном месте.
Нам нужна одна функция, которая применяет таблицу. Тогда все команды будут одинаковыми автоматически.
Код констант:
package apperr
const (
ExitOK = 0
ExitFail = 1
ExitUsage = 2
)
И код функции:
package apperr
func ExitCode(err error) int {
if err == nil {
return ExitOK
}
if KindOf(err) == KindValidation {
return ExitUsage
}
return ExitFail
}
Здесь намеренно «слишком просто». В этом и смысл: базовый режим никогда не должен превращаться в «тут 7 if’ов и своя религия».
5. Расширенные коды: отдельный режим, а не замена базовому
Да, бывают случаи, когда вам реально полезно отличать notfound от io. Например, если скрипт хочет «мягко» обработать отсутствие задачи, но «жёстко» падать на проблеме с диском.
Однако если вы просто поменяете базовую ExitCode и начнёте возвращать 3 для notfound, вы ломаете ожидания пользователей и скриптов. Поэтому расширение должно быть опциональным и включаться явно (флагом, настройкой, отдельной командой), но не «вдруг стало по-другому».
Пример таблицы расширенных кодов
Расширенная таблица — это «надстройка». Она может меняться, может быть специфичной для проекта, но она не должна подменять базовую. То есть у вас всегда есть безопасный режим 0/1/2, и только по запросу — подробности.
| Kind | Exit code | Комментарий |
|---|---|---|
|
|
остаётся usage, не трогаем |
|
|
отдельная семантика |
|
|
отдельно для проблем I/O |
|
|
как и раньше |
|
|
как и раньше |
Реализация отдельной функцией:
package apperr
func ExitCodeDetailed(err error) int {
if err == nil {
return ExitOK
}
switch KindOf(err) {
case KindValidation:
return ExitUsage
case KindNotFound:
return 3
case KindIO:
return 4
default:
return ExitFail
}
}
Здесь важен архитектурный момент: мы не меняем ExitCode. Мы добавляем отдельную функцию. Технически это чуть длиннее, но по смыслу — честнее и стабильнее для пользователей.
6. Пример на CLI todo: как классы ошибок превращаются в коды
Сделаем это не теорией, а ситуациями. Представим, что команды выглядят так:
- todo add -title "купить молоко"
- todo done -id 10
А данные лежат в файле (например, JSON), и файл иногда может не читаться.
Validation: пользователь забыл обязательный флаг
Ошибки валидации — самые «дружелюбные». Это не катастрофа, это повод показать usage и подсказать, как правильно. И именно поэтому мы обязаны возвращать 2, чтобы скрипты понимали: «это не падение системы, это неправильный вызов».
package todo
import (
"errors"
"example.com/todo/internal/apperr"
)
func parseTitle(title string) error {
if title == "" {
return &apperr.AppError{
Kind: apperr.KindValidation,
Op: "parse -title",
Err: errors.New("title is required"),
}
}
return nil
}
Такой err обязан превращаться в 2 через apperr.ExitCode(err).
NotFound: задача с id не существует
notfound часто вызывает философский спор: «это ошибка или просто отсутствие результата?». В CLI обычно это всё-таки ошибка выполнения команды: вы просили сделать действие, но объект не найден. Поэтому в базовом режиме это будет 1, а в расширенном — отдельный код, если вам нужно.
package todo
import (
"errors"
"fmt"
"example.com/todo/internal/apperr"
)
func markDone(id int) error {
if id == 42 {
return nil
}
return &apperr.AppError{
Kind: apperr.KindNotFound,
Op: fmt.Sprintf("mark done id=%d", id),
Err: errors.New("task not found"),
}
}
IO: файл не читается
I/O‑ошибка — классика «всё было правильно, но мир оказался сложнее». Путь к файлу может быть неверным, прав не хватить, диск может быть занят. Это не «плохой ввод команды» в смысле usage, поэтому в базовой схеме это 1.
package storage
import (
"fmt"
"os"
"example.com/todo/internal/apperr"
)
func load(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return &apperr.AppError{
Kind: apperr.KindIO,
Op: "read tasks file",
Err: fmt.Errorf("%w", err),
}
}
return nil
}
Здесь %w важен, чтобы ошибка не теряла первопричину и могла нормально диагностироваться: errors.Is/errors.As должны уметь пройти по цепочке.
7. Где принимать решение об exit code: граница приложения
Не нужно вычислять exit code в середине бизнес‑логики. Логике задач вообще не должно быть дела до того, что где-то есть «код процесса». Это забота границы CLI: место, где вы превращаете результат работы в поведение процесса.
Классический подход: вы возвращаете ошибку наружу, а наверху решаете «что печатать» и «каким кодом завершиться». Для CLI это обычно выглядит так: run() error делает работу, main() печатает и вызывает os.Exit(code).
И да: мы помним, что после os.Exit deferred‑вызовы не выполняются, поэтому os.Exit должен быть только вверху.
Мини‑каркас:
package main
import (
"fmt"
"os"
"example.com/todo/internal/apperr"
)
func main() {
err := run()
code := apperr.ExitCode(err)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
}
os.Exit(code)
}
Этот кусок нарочно простой: здесь мы фиксируем именно таблицу кодов. «Красивые сообщения пользователю» и «логирование деталей» живут рядом, но не смешиваются с расчётом exit code.
8. Типичные ошибки при построении таблицы exit codes
Ошибка №1: разные команды возвращают разные коды за одну и ту же ситуацию.
Это самый неприятный дрейф: todo add за «не хватило -title» возвращает 2, а todo done за «не хватило -id» возвращает 1. Пользователь в скриптах начинает писать костыли, а вы — ловить баги «почему CI иногда красный». Лечится это просто: одна функция ExitCode(err) и единая таблица, а не os.Exit(...) в каждой команде.
Ошибка №2: notfound внезапно считается успехом, потому что «ну не нашли и ладно».
Иногда так делают, чтобы «не шуметь». Но тогда команда вроде todo done -id 999 в автоматизации выглядит успешной, хотя задачу никто не отметил. Если вам правда нужен «мягкий notfound», это должно быть явным режимом или другой командой, а не случайной трактовкой. В базовой схеме notfound — это 1, а если нужно иначе, включайте расширенный режим и документируйте его.
Ошибка №3: вычисление exit code по строке ошибки (err.Error()).
Это ломается при первом же рефакторинге текста, локализации или добавлении контекста. Нормальный путь — структурировать ошибку (тип + поля), сохранять причины через wrapping и извлекать смысл через errors.As/errors.Is.
Ошибка №4: расширенные коды тихо заменяют базовые.
Сегодня вы выдали 3 для notfound, завтра решили, что notfound — это 4, потому что «мы так чувствуем». Скрипты пользователей не умеют чувствовать, они умеют падать. Расширение должно быть отдельной функцией/режимом, а базовый 0/1/2 — оставаться неизменным.
Ошибка №5: os.Exit вызывается в середине кода команды.
В итоге вы теряете контроль над выводом, ломаете deferred‑закрытия файлов и превращаете код в набор «точек внезапного исчезновения процесса». Гораздо спокойнее возвращать ошибку наверх и завершать процесс только на границе CLI, когда exit code уже вычислен по таблице.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ