Оборачивание ошибок в Go
Ошибка родилась в драйвере базы, прошла репозиторий, сервис и обработчик, и в лог попала строчка «слой репозитория: слой драйвера: не найдено». Каждый уровень добавил своё, но исходная ошибка не потерялась - её по-прежнему можно опознать программно. Это и есть оборачивание, и держится оно на одном глаголе форматирования.
Стержень: %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, остальные разбираются в тренажёре.
- Чем errors.Is отличается от errors.As?A)Is для своих ошибок, а As — для чужих из стандартной библиотекиB)Is сравнивает тексты сообщений, а As сравнивает коды ошибокC)Это синонимы, а As оставлен лишь для обратной совместимости со старым GoD)Is ищет в цепочке заданное значение (sentinel), As — ошибку нужного типа
показать ответ и разбор
+D)Is ищет в цепочке заданное значение (sentinel), As — ошибку нужного типа// разбор: errors.Is(err, target) идёт по цепочке обёрток и проверяет, не равна ли какая-то ошибка конкретному ЗНАЧЕНИЮ target (sentinel вроде io.EOF), либо совпала по методу Is. errors.As(err, &target) ищет в цепочке ошибку конкретного ТИПА и, найдя, присваивает её в target, чтобы прочитать поля. Правило: сравниваешь с известным значением — Is; нужен тип и его данные — As.
- Что делает errors.Join(err1, err2)?A)Складывает ошибки в цепочку, где Unwrap отдаёт их по однойB)Соединяет тексты ошибок в одну строку, теряя исходные значенияC)Возвращает ошибку, для которой errors.Is истинна по каждой из вложенныхD)Возвращает первую ненулевую ошибку из переданных
показать ответ и разбор
+C)Возвращает ошибку, для которой errors.Is истинна по каждой из вложенных// разбор: Join собирает несколько ошибок в одну: errors.Is находит каждую из вложенных, а текст склеивается через перевод строки. Если все аргументы nil, вернётся nil — проверять заранее не нужно. Пригодно там, где сбоев может быть несколько сразу: валидация формы, параллельные задачи, закрытие пачки ресурсов.
- Когда ошибку из нижнего слоя НЕ стоит оборачивать и пробрасывать наверх как есть?A)Когда она содержит детали, которые нельзя показывать наружуB)Когда её текст длиннее нескольких строк и загромождает логC)Когда вызывающий код всё равно не проверяет её через errors.IsD)Когда она получена из стандартной библиотеки, а не из своего кода
показать ответ и разбор
+A)Когда она содержит детали, которые нельзя показывать наружу// разбор: Ошибка драйвера БД может нести имя схемы, кусок запроса или адрес хоста — наружу такое не отдают. На границе (обработчик HTTP, публичный API) её подменяют доменной ошибкой, а исходную оставляют в логе для разбора. Второй повод не оборачивать — когда обёртка не добавляет контекста: «ошибка при чтении: ошибка при чтении» только зашумляет.
- Что нужно типу, чтобы 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 тоже бывает полезен — но для другого: им переопределяют логику сравнения, когда равенства значений недостаточно.
- Зачем ошибку из нижнего слоя оборачивают, добавляя свой текст?A)Чтобы автоматически повторить сорвавшуюся операциюB)Чтобы заменить исходную ошибку на более понятнуюC)Чтобы ошибка перестала подниматься выше по стекуD)Чтобы к причине добавился контекст
показать ответ и разбор
+D)Чтобы к причине добавился контекст// разбор: «Соединение отказано» само по себе бесполезно, а «загрузка профиля 42: соединение отказано» уже ведёт к месту сбоя. Обёртка через %w наращивает цепочку, сохраняя исходную ошибку доступной для errors.Is и errors.As.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.