JavaRush /Курсы /Go SELF /Таблица «класс ошибки → exit code» для CLI

Таблица «класс ошибки → exit code» для CLI

Go SELF
55 уровень , 2 лекция
Открыта

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 Почему
err == nil
команда выполнилась
0
успех
KindValidation
пользователь неправильно использовал команду
2
это usage‑ошибка
KindNotFound
не найден ресурс/сущность
1
это не usage, но и не успех
KindIO
чтение/запись не сработали
1
ошибка выполнения
KindInternal
«что-то сломалось»
1
ошибка выполнения

Обратите внимание: в базовой политике 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 Комментарий
validation
2
остаётся usage, не трогаем
notfound
3
отдельная семантика
io
4
отдельно для проблем I/O
internal
1
как и раньше
nil
0
как и раньше

Реализация отдельной функцией:

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 уже вычислен по таблице.

1
Задача
Go SELF, 55 уровень, 2 лекция
Недоступна
Таблица выхода
Таблица выхода
1
Задача
Go SELF, 55 уровень, 2 лекция
Недоступна
Класс из цепочки
Класс из цепочки
1
Задача
Go SELF, 55 уровень, 2 лекция
Недоступна
Два режима
Два режима
1
Задача
Go SELF, 55 уровень, 2 лекция
Недоступна
Карманный CLI
Карманный CLI
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ