Ошибки — это значения: обёртывание, сентинелы и дисциплина if err != nil
Ошибки в Go — значения: каждую err обработай или сознательно отбрось. Оборачивай через %w — errors.Is/As переживают слои, а == ломается после первой обёртки. Сентинел, тип или непрозрачность — выбор уровня связности. panic — для багов; проглоченная ошибка взрывается вдали.
API профилей пользователей год жил на одном сравнении: if err == sql.ErrNoRows означало «профиля нет, вернуть 404». Потом коллега сделал правильную вещь — добавил контекст к ошибкам в слое репозитория: return fmt.Errorf("get profile %d: %w", id, err). Тесты прошли; обёртывание даже сделало логи приятнее. В продакшене каждый запрос несуществующего профиля стал возвращать 500. Проверка == сравнивала обёрнутую ошибку с голым сентинелом, получала false, и хендлер проваливался в ветку «отказ базы данных». Алертинг будил людей в два ночи из-за того, что на самом деле было «пользователь опечатался в имени». Фикс — один токен: errors.Is(err, sql.ErrNoRows), но урок структурный: как только любой слой оборачивает, сравнение по идентичности умирает, и цепочка ошибок Go работает, только если каждый слой согласен по ней ходить.
Ошибки — значения, и это осознанная позиция
Когда вы впервые пишете Go после языка с исключениями, блоки if err != nil кажутся шумом. Они не шум — они и есть суть. В Go error — просто интерфейс с одним методом Error() string, а ошибка — обычное значение, которое возвращают, проверяют, складывают в срез, сравнивают. Ритуал if err != nil, над которым смеются новички, и есть суть: каждый путь отказа виден в коде, который по нему идёт. Go отказался от исключений намеренно. С исключениями любая строка может передать управление обработчику за много страниц отсюда — чтение функции ничего не говорит о её путях выхода; со значениями-ошибками поток управления — ровно то, что написано. Цена — повторение: if err != nil { return ... } повсюду, и Go принимает эту цену сознательно: скучная, явная обработка отказов читается одинаково в любой кодовой базе, а эти три строки — место, где вы добавляете контекст, а не просто пробрасываете ошибку вверх.
func loadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %q: %w", path, err)
}
cfg, err := parse(raw)
if err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
return cfg, nil
}Каждый return аннотирует ошибку тем, что делал этот слой, и итоговое сообщение читается как причинная цепочка: load config "/etc/app.yaml": open /etc/app.yaml: permission denied. Стек-трейс не нужен — цепочка и есть трейс, на языке предметной области.
Обёртывание: %w строит цепочку, errors.Is и errors.As по ней ходят
fmt.Errorf с глаголом %w не просто форматирует — он сохраняет обёрнутую ошибку, давая новой ошибке метод Unwrap() error. Наслоите достаточно уровней — получите связный список ошибок. По нему ходят две функции. errors.Is(err, target) идёт по цепочке, спрашивая «является ли какое-то звено именно этим значением?» — безопасная к обёртыванию замена == и однотокенный фикс пятисоток из вступления. errors.As(err, &target) идёт по цепочке, спрашивая «есть ли звено этого типа?», и копирует совпадение наружу — так вы достаёте типизированные данные (код *pgconn.PgError, путь *os.PathError), не зная, сколько слоёв их обернуло.
_, err := repo.GetProfile(ctx, id)
if errors.Is(err, sql.ErrNoRows) { // работает через любое число %w-обёрток
return nil, ErrProfileNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrDuplicate // типизированные данные, извлечённые из цепочки
}Ловушка — %v против %w: %v сплющивает ошибку в текст и отбрасывает цепочку — errors.Is через него возвращает false. Используйте %w, когда вызывающим может понадобиться причина; используйте %v намеренно, когда хотите запечатать ошибку на границе, чтобы вызывающие не связались с внутренностями. Этот выбор — дизайн API: каждый %w делает обёрнутую ошибку частью вашего контракта — смените потом sql.ErrNoRows на другой драйвер, и проверки errors.Is у вызывающих молча перестанут совпадать.
Сентинел, типизированная, непрозрачная: три уровня связности
Есть три идиомы, упорядоченные по тому, сколько обязан знать вызывающий. Сентинел-ошибки — значения уровня пакета вроде io.EOF, sql.ErrNoRows или ваш var ErrNotFound = errors.New("not found") — дёшевы (сравнение указателей через errors.Is) и позволяют ветвиться по идентичности, но это вечный публичный API без данных. Типизированные ошибки — структуры, реализующие error, как *os.PathError, — несут поля (какой путь? какой код?) и матчатся через errors.As, ценой экспорта типа, вокруг которого вызывающие выстроят ожидания. Непрозрачные ошибки — просто вернуть error, ничего не выставляя — минимизируют связность: вызывающий может только залогировать или пробросить, а вы свободны менять всё за текстом сообщения. Сеньорский дефолт — сначала непрозрачно: экспортируйте сентинел или тип, только когда вызывающему доказуемо нужно ветвиться на этом отказе, потому что каждая экспортированная ошибка — контракт, который вы будете поддерживать вечно.
▸Почему это работает
Почему Go по умолчанию не снимает стек-трейсы в ошибках? Стек-трейс говорит, где ошибка всплыла; цепочка обёрток — что программа пыталась сделать: «load config /etc/app.yaml: open: permission denied» читается оператором, который никогда не видел кода. Трейсы ещё и стоят: снять один — значит пройти по стеку в момент создания ошибки, что больно на горячих путях, где ошибки ожидаемы (io.EOF срабатывает буквально на каждом завершённом чтении — трейсить его значило бы обложить налогом каждое копирование файла в программе). Ставка Go: ошибки — значения, достаточно дешёвые для обычного потока управления, с контекстом, добавляемым явно там, где он осмыслен. А когда трейс действительно нужен — по-настоящему неожиданный отказ — его бесплатно печатает panic.
panic — для багов; настоящий убийца — проглоченная ошибка
panic раскручивает стек, выполняя отложенные функции, пока его не остановит recover — или процесс не завершится. Его работа — невосстановимая ошибка программиста: индекс за границей, запись в nil-map, «эта ветка невозможна». Это не канал потока управления для ожидаемых отказов — отсутствующая строка в базе не повод для panic. recover используйте только на границах горутин — в per-request middleware HTTP-сервера, в обёртке worker-пула — чтобы превратить «один багованный запрос» в 500 вместо мёртвого процесса. И деталь, убивающая сервисы: recover работает только внутри отложенной функции паникующей горутины. Паника в горутине, запущенной через go, игнорирует все recover родителя — и роняет весь процесс. Каждая go func(), выполняющая сторонний или зависящий от запроса код, обязана иметь свой отложенный recover.
Самый частый продакшен-отказ — вовсе не паника, а проглоченная ошибка:
data, _ := fetch(ctx, key) // err отброшен: data — НУЛЕВОЕ ЗНАЧЕНИЕ
process(data) // nil-разыменование или пустой результат — далеко от причины
if err := step(); err != nil {
log.Printf("step failed: %v", err)
// ...and execution continues as if it succeeded
}Отбросьте через _ — и нулевое значение потечёт вниз по потоку, взорвавшись nil-разыменованием или молча пустым ответом в коде, который выглядит невинно: место падения и причина окажутся в разных файлах. «Залогировать и продолжить» коварнее: функция работает дальше со сломанным состоянием, а строчка лога проматывается непрочитанной. Дисциплина: каждая err либо обработана (по ней ветвление, компенсация), либо возвращена с контекстом %w, либо — редко — отброшена через _ с комментарием, почему это безопасно. Третьего не дано.
Функция репозитория GetUser оборачивает sql.ErrNoRows в слое данных. Сервисному слою нужно отличить «пользователя нет» от настоящей ошибки БД. Выберите правильную идиому ошибок.
Хендлер проверяет err == sql.ErrNoRows, чтобы вернуть 404. Слой репозитория обновили: теперь он возвращает fmt.Errorf("get user: %w", err). Что станет с запросами несуществующих строк?
У HTTP-сервера есть middleware восстановления (отложенный recover) вокруг каждого хендлера. Хендлер запускает go enrichAsync(req), и эта горутина паникует на записи в nil-map. Что произойдёт?
- 01Как механически работает обёртывание ошибок и почему == ломается, а errors.Is продолжает работать?
- 02Когда использовать сентинел, типизированную и непрозрачную ошибку и каковы правила panic/recover?
Go обращается с ошибками как с обычными значениями, возвращаемыми из функций, и ритуал if err != nil — осознанная позиция дизайна: каждый путь отказа написан там, где случается, в обмен на повторение, которое заодно служит местом добавления контекста. Обёртывание через fmt.Errorf с глаголом %w сохраняет причину и выставляет её через Unwrap, строя причинную цепочку, читающуюся как трейс на языке домена; errors.Is ходит по этой цепочке, матча значения-сентинелы — безопасная к обёрткам замена ==, чей отказ под обёртыванием превратил уборку в слое репозитория в продакшен-пятисотки, — а errors.As ходит по ней, находя и извлекая типизированные ошибки с данными, вроде кода ошибки драйвера. Глагол %v форматирует, но обрывает цепочку: баг, когда вызывающим ещё нужна причина, и легитимный запечатывающий ход на границах API. Три идиомы упорядочивают связность: непрозрачные ошибки держат вызывающих на расстоянии и являются дефолтом; сентинелы вроде sql.ErrNoRows позволяют ветвиться по идентичности, но это вечный API без данных; типизированные ошибки несут поля ценой экспорта типа. panic существует для невосстановимых ошибок программиста, никогда — для ожидаемых отказов; recover уместен только на границах горутин, например в per-request middleware, и не пересекает горутины — паника в запущенной горутине игнорирует родительский middleware и убивает процесс, поэтому каждая go func с рискованным кодом несёт собственный отложенный recover. Самая частая продакшен-рана — самонанесённая: ошибка, отброшенная подчёркиванием или залогированная и проигнорированная, отправляет нулевое значение вниз, где оно взрывается далеко от причины. Каждая err обработана, возвращена обёрнутой или отброшена с письменным оправданием. Теперь, когда видите err == somePackage.ErrFoo на код-ревью, знаете, какой вопрос задать: добавил ли хоть один слой между источником и этой проверкой обёртку %w? Если да — это сравнение уже сломано.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.