open atlas
↑ К треку
Go с нуля до senior GO · 01 · 02

Ошибки — это значения: обёртывание, сентинелы и дисциплина if err != nil

Ошибки в Go — значения: каждую err обработай или сознательно отбрось. Оборачивай через %w — errors.Is/As переживают слои, а == ломается после первой обёртки. Сентинел, тип или непрозрачность — выбор уровня связности. panic — для багов; проглоченная ошибка взрывается вдали.

GO Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

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. Что произойдёт?

Вспомните перед уходом
  1. 01
    Как механически работает обёртывание ошибок и почему == ломается, а errors.Is продолжает работать?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.