сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

Оборачивание ошибок в Go

Обёртки, Is, As и Join

Ошибка родилась в драйвере базы, прошла репозиторий, сервис и обработчик, и в лог попала строчка «слой репозитория: слой драйвера: не найдено». Каждый уровень добавил своё, но исходная ошибка не потерялась - её по-прежнему можно опознать программно. Это и есть оборачивание, и держится оно на одном глаголе форматирования.

Стержень: %w встраивает ошибку в цепочку, %v превращает её в текст; errors.Is ищет по цепочке значение, errors.As - тип.

// Формулировки: «чем %w отличается от %v?», «когда Is, а когда As?», «что делает errors.Join?»

Цепочка держится на %w

fmt.Errorf с глаголом %w создаёт ошибку, которая хранит внутри исходную и умеет отдавать её методом Unwrap. Получается связный список: каждый слой ссылается на предыдущий.

С %v такого не происходит. Ошибка превращается в текст, и текст этот выглядит совершенно так же - разница видна только программно. Проверено: errors.Is по цепочке, собранной через %w, возвращает true, а по такой же на вид строке через %v - false. Одна буква в формате, и обработка выше ломается молча.

errors.Unwrap снимает ровно один слой. Ходить по цепочке руками почти никогда не нужно: errors.Is и errors.As делают это сами, до самого дна.

// С Go 1.20 в одном Errorf можно указать несколько %w сразу - проверено, errors.Is находит обе обёрнутые ошибки. Удобно, когда операция провалилась по двум причинам одновременно.

err := fmt.Errorf("слой репозитория: %w",
        fmt.Errorf("слой драйвера: %w", ErrNotFound))

err.Error()                     // слой репозитория: слой драйвера: не найдено
errors.Is(err, ErrNotFound)     // true  - цепочка цела
errors.Unwrap(err)              // слой драйвера: не найдено (один слой)

bad := fmt.Errorf("через %v", ErrNotFound)
errors.Is(bad, ErrNotFound)     // false - текст тот же, связи нет
%w
глагол fmt.Errorf, который сохраняет исходную ошибку в цепочке; %v оставляет только текст

Is ищет значение, As ищет тип

errors.Is отвечает на вопрос «это та самая ошибка?». Идёт по цепочке и сравнивает каждое звено с эталоном. Применяют к sentinel-значениям: ErrNotFound, sql.ErrNoRows, io.EOF (end of file, признак конца потока), os.ErrPermission.

errors.As отвечает на вопрос «есть ли в цепочке ошибка такого типа, и если да, дай её мне». Вторым аргументом передают указатель на переменную нужного типа, туда кладётся найденное звено. Применяют, когда из ошибки нужны детали: код ответа, номер поля, имя таблицы.

Проверено: для ошибки, обёрнутой поверх &MyErr{Code: 404}, errors.As возвращает true и заполняет переменную, после чего Code читается как обычное поле. Именно за этим As и нужен - Is сказал бы только «да или нет».

// Прямое сравнение err == ErrNotFound работает, только пока ошибку никто не обернул. Как только между слоями появится хоть один Errorf с %w, сравнение начнёт возвращать false, а errors.Is продолжит работать. Поэтому в новом коде пишут Is сразу, даже когда обёрток пока нет.

var me *MyErr
if errors.As(err, &me) {
    log.Printf("код ответа: %d", me.Code)   // 404 - детали доступны
}

if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

Join и границы оборачивания

errors.Join собирает несколько независимых ошибок в одну. Проверено: текст получается склейкой через перевод строки, а errors.Is находит каждую из вложенных. Естественное применение - валидация формы, где надо вернуть все нарушения сразу, а не первое попавшееся.

Теперь про границу. Оборачивание с %w делает исходную ошибку частью публичного контракта: пользователи начнут писать errors.Is на неё, и убрать её ты уже не сможешь, не сломав их код. Ошибку драйвера базы, случайно протёкшую наружу через %w, придётся поддерживать вечно.

Поэтому на границе пакета решают осознанно: наружу отдаём свою ошибку через %v или через собственный sentinel, а внутренние детали оставляем внутри. Оборачиваем через %w только то, что действительно хотим показать наружу как часть контракта.

// Практическое следствие: внутри пакета оборачивай смело, на публичном API - подумав. Вопрос простой: захотят ли пользователи различать эту причину программно? Если да - %w и документируй. Если нет - %v.

errors.Join
объединяет несколько ошибок в одну; Is находит каждую, текст склеивается переводами строк

Как отвечать: «Чем %w отличается от %v и когда Is, а когда As?»

%w встраивает исходную ошибку в цепочку и добавляет метод Unwrap, а %v просто вставляет её текст. Внешне строка получается одинаковой, и в этом главная опасность: с %v errors.Is перестаёт находить ошибку, я это проверял - на одинаковых на вид строках Is возвращает true и false соответственно. То есть обработка наверху ломается молча, без единого предупреждения компилятора. Дальше выбор между Is и As зависит от вопроса. Is отвечает «это та самая ошибка?» - для sentinel-значений вроде sql.ErrNoRows или io.EOF. As отвечает «есть ли в цепочке ошибка такого типа, дай её мне» - когда нужны детали: код, номер поля, имя таблицы. Оба идут по всей цепочке до дна, руками Unwrap звать почти никогда не нужно. Прямое сравнение через двойное равно я стараюсь не использовать: оно ломается от первой же обёртки. И держу в голове границу: обёртка через %w делает вложенную ошибку частью публичного контракта, поэтому наружу пакета так пробрасываю только то, что готов поддерживать.

Разница названа механизмом и подтверждена проверкой, критерий выбора между Is и As сформулирован вопросом, и добавлено рассуждение про контракт - его на собеседовании слышат редко.

На чём валят

  • Пишут %v вместо %w и молча ломают errors.Is у всех, кто выше.
  • Сравнивают ошибки через двойное равно - первая же обёртка ломает сравнение.
  • Берут Is там, где нужны детали, и остаются без кода ошибки.
  • Передают в As значение вместо указателя на переменную нужного типа.
  • Оборачивают через %w внутренние ошибки на публичном API и обрекают себя их поддерживать.
  • Не знают про errors.Join и возвращают из валидации только первую ошибку.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.

  1. #go_err_wrap1 / 5
    Чем errors.Is отличается от errors.As?
    A)Is для своих ошибок, а As — для чужих из стандартной библиотеки
    B)Is сравнивает тексты сообщений, а As сравнивает коды ошибок
    C)Это синонимы, а As оставлен лишь для обратной совместимости со старым Go
    D)Is ищет в цепочке заданное значение (sentinel), As — ошибку нужного типа
    показать ответ и разбор
    +D)Is ищет в цепочке заданное значение (sentinel), As — ошибку нужного типа

    // разбор: errors.Is(err, target) идёт по цепочке обёрток и проверяет, не равна ли какая-то ошибка конкретному ЗНАЧЕНИЮ target (sentinel вроде io.EOF), либо совпала по методу Is. errors.As(err, &target) ищет в цепочке ошибку конкретного ТИПА и, найдя, присваивает её в target, чтобы прочитать поля. Правило: сравниваешь с известным значением — Is; нужен тип и его данные — As.

  2. #go_err_wrap2 / 5
    Что делает errors.Join(err1, err2)?
    A)Складывает ошибки в цепочку, где Unwrap отдаёт их по одной
    B)Соединяет тексты ошибок в одну строку, теряя исходные значения
    C)Возвращает ошибку, для которой errors.Is истинна по каждой из вложенных
    D)Возвращает первую ненулевую ошибку из переданных
    показать ответ и разбор
    +C)Возвращает ошибку, для которой errors.Is истинна по каждой из вложенных

    // разбор: Join собирает несколько ошибок в одну: errors.Is находит каждую из вложенных, а текст склеивается через перевод строки. Если все аргументы nil, вернётся nil — проверять заранее не нужно. Пригодно там, где сбоев может быть несколько сразу: валидация формы, параллельные задачи, закрытие пачки ресурсов.

  3. #go_err_wrap3 / 5
    Когда ошибку из нижнего слоя НЕ стоит оборачивать и пробрасывать наверх как есть?
    A)Когда она содержит детали, которые нельзя показывать наружу
    B)Когда её текст длиннее нескольких строк и загромождает лог
    C)Когда вызывающий код всё равно не проверяет её через errors.Is
    D)Когда она получена из стандартной библиотеки, а не из своего кода
    показать ответ и разбор
    +A)Когда она содержит детали, которые нельзя показывать наружу

    // разбор: Ошибка драйвера БД может нести имя схемы, кусок запроса или адрес хоста — наружу такое не отдают. На границе (обработчик HTTP, публичный API) её подменяют доменной ошибкой, а исходную оставляют в логе для разбора. Второй повод не оборачивать — когда обёртка не добавляет контекста: «ошибка при чтении: ошибка при чтении» только зашумляет.

  4. #go_err_wrap4 / 5
    Что нужно типу, чтобы errors.Is и errors.As видели ошибку сквозь обёртку?
    A)Метод Is(error) bool, объявленный на этом типе
    B)Регистрация типа ошибки в пакете errors при инициализации
    C)Метод Unwrap() error, возвращающий вложенную ошибку
    D)Реализация интерфейса fmt.Stringer вдобавок к error
    показать ответ и разбор
    +C)Метод Unwrap() error, возвращающий вложенную ошибку

    // разбор: Обе функции идут по цепочке, вызывая Unwrap, пока он есть. fmt.Errorf с %w такой метод создаёт автоматически, а своему типу-ошибке его добавляют руками, если он хранит вложенную. Метод Is(error) bool тоже бывает полезен — но для другого: им переопределяют логику сравнения, когда равенства значений недостаточно.

  5. #go_err_wrap5 / 5
    Зачем ошибку из нижнего слоя оборачивают, добавляя свой текст?
    A)Чтобы автоматически повторить сорвавшуюся операцию
    B)Чтобы заменить исходную ошибку на более понятную
    C)Чтобы ошибка перестала подниматься выше по стеку
    D)Чтобы к причине добавился контекст
    показать ответ и разбор
    +D)Чтобы к причине добавился контекст

    // разбор: «Соединение отказано» само по себе бесполезно, а «загрузка профиля 42: соединение отказано» уже ведёт к месту сбоя. Обёртка через %w наращивает цепочку, сохраняя исходную ошибку доступной для errors.Is и errors.As.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.