Ошибки в Go
В Go нет исключений: функция, в которой что-то может пойти не так, просто возвращает ошибку обычным return, а что с ней делать, решает вызывающий код. if err != nil – самая частая конструкция в языке, и за ее внешней простотой прячется целая система. В этой статье будем препарировать устройство ошибок в Go.
Ошибки – это значения
Errors are values – так звучит один из Go proverbs ↗. Ошибка в Go – не специальная конструкция языка вроде исключения, а обычное значение, такой же first-class citizen, как число или строка: ее можно присвоить переменной, передать в функцию, положить в слайс, сравнить с другим значением.
Представлена ошибка интерфейсом error. Это предобъявленный идентификатор – он доступен без импорта, а его описание живет в документационном пакете builtin:
// builtin/builtin.go
type error interface {
Error() string
}goОшибкой может стать любой тип с методом Error() string – благодаря неявной реализации интерфейсов объявлять ничего не нужно. Самый простой способ создать ошибку – функция New пакета errors:
package values
import "errors"
var ErrInsufficientBalance = errors.New("insufficient balance")goВернем эту ошибку, если у пользователя не хватает денег на покупку:
package values
func buyItem(user User, item Item) error {
if user.Balance < item.Cost {
return ErrInsufficientBalance
}
return nil
}goВызывающий код проверяет ошибку сравнением с nil:
user := User{Balance: 100}
item := Item{Cost: 640}
if err := buyItem(user, item); err != nil {
fmt.Println(err)
}goinsufficient balanceЭкспортированная переменная уровня пакета, как ErrInsufficientBalance, называется сентинельной ошибкой (sentinel error). Самый известный сентинел – io.EOF: его возвращает Reader, когда читать больше нечего. К сентинелам мы еще вернемся в разделе о способах создания ошибок.
Ошибка под капотом
Посмотрим, что создает errors.New:
// errors/errors.go
// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}goСтруктура с единственным полем – переданной строкой. Вся «магия» ошибок Go сводится к таким простым значениям.
Почему errors.New возвращает указатель
В комментарии к New есть строка, мимо которой легко пройти: Each call to New returns a distinct error value even if the text is identical – каждый вызов возвращает различимое значение, даже если текст совпадает. Проверим:
a := errors.New("insufficient balance")
b := errors.New("insufficient balance")
fmt.Println(a == b)
fmt.Println(a == ErrInsufficientBalance)gofalse
falseДве ошибки с одинаковым текстом не равны. Причина – в том, что New возвращает указатель *errorString. При сравнении интерфейсных значений сравниваются динамические типы и динамические значения, а два указателя равны, только когда указывают на один и тот же объект в памяти.
Именно это свойство делает сентинелы безопасными. Если бы New возвращал структуру по значению, любой пакет мог бы «подделать» вашу ошибку, создав ошибку с тем же текстом. С указателем совпадение возможно только с самой переменной-сентинелом:
err := buyItem(User{Balance: 100}, Item{Cost: 640})
fmt.Println(err == ErrInsufficientBalance)
fmt.Println(err == errors.New("insufficient balance"))gotrue
falseОшибка – это обычный интерфейс
У error нет никакого специального статуса в рантайме: это обычное интерфейсное значение из двух машинных слов – указателя на таблицу типа и указателя на данные.
Из этого устройства растет самая известная ловушка Go: если функция объявляет возвращаемый тип *MyError и возвращает nil, то после присваивания результата переменной типа error проверка err != nil сработает – в интерфейсе окажется typed nil с заполненным словом типа. Мы подробно разобрали эту ловушку с диаграммой в статье про интерфейсы, в разделе «Ловушка с err != nil». Здесь зафиксируем только вывод: функция, возвращающая ошибку, должна объявлять тип error, а не конкретный тип ошибки.
Способы создания ошибок
Ошибки различаются тем, какой контракт они дают вызывающему коду. Таких контрактов три.
Непрозрачные ошибки
Самый слабый контракт – его отсутствие. Ошибка, созданная fmt.Errorf без экспортированной переменной или типа, называется непрозрачной (opaque):
err := fmt.Errorf("parse config: unexpected symbol at line %d", 3)
fmt.Println(err)goparse config: unexpected symbol at line 3Вызывающий код может узнать только одно: ошибка есть или нет. Зато пакет ничего не обещает и может менять текст ошибки и ее причины, не ломая вызывающий код. Поэтому непрозрачная ошибка – правильный выбор по умолчанию: заводите идентичность ошибке только тогда, когда вызывающему коду действительно нужно ее различать.
Сентинельные ошибки
Сентинел – экспортированная переменная уровня пакета, как ErrInsufficientBalance или io.EOF. Контракт сильнее: вызывающий код может распознать конкретный исход и по-разному на него отреагировать. Добавим магазину второй сентинел – товар закончился:
package sentinel
import "errors"
var (
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOutOfStock = errors.New("out of stock")
)gopackage sentinel
func buyItem(user User, item Item) error {
if item.Stock == 0 {
return ErrOutOfStock
}
if user.Balance < item.Cost {
return ErrInsufficientBalance
}
return nil
}goТеперь оформление заказа подсказывает пользователю, что делать дальше:
package sentinel
func checkout(user User, item Item) string {
switch buyItem(user, item) {
case nil:
return "Заказ оформлен"
case ErrInsufficientBalance:
return "Пополните баланс"
case ErrOutOfStock:
return "Сообщим, когда товар появится"
default:
return "Что-то пошло не так, попробуйте позже"
}
}gofmt.Println(checkout(User{Balance: 1000}, Item{Cost: 640, Stock: 3}))
fmt.Println(checkout(User{Balance: 100}, Item{Cost: 640, Stock: 3}))
fmt.Println(checkout(User{Balance: 1000}, Item{Cost: 640, Stock: 0}))goЗаказ оформлен
Пополните баланс
Сообщим, когда товар появитсяСравнение через == работает, пока buyItem возвращает сентинел как есть. Стоит его обернуть – и проверка сломается; чуть ниже увидим почему и чем ее заменить.
Сентинела достаточно, когда вызывающему коду нужно знать только, что случилось: «не найдено», «конец данных», «недостаточно средств».
Кастомные типы ошибок
Когда вызывающему коду нужны данные об ошибке, сентинела мало. ErrInsufficientBalance сообщает, что денег не хватает, но не сколько – и оформление заказа не может подсказать, на какую сумму пополнить баланс. Ошибки – это значения, поэтому ничто не мешает объявить собственный тип-структуру с полями. Чтобы он стал ошибкой, достаточно имплементировать метод Error() string.
Положим недостающую сумму в поле Diff:
package custom
import "fmt"
type InsufficientBalanceError struct {
Diff int
}
func (e *InsufficientBalanceError) Error() string {
return fmt.Sprintf("insufficient balance, need %d more money", e.Diff)
}gopackage custom
func buyItem(user User, item Item) error {
if user.Balance < item.Cost {
return &InsufficientBalanceError{Diff: item.Cost - user.Balance}
}
return nil
}goОформление заказа распознает ошибку по типу через type switch и берет сумму из поля:
package custom
import "fmt"
func checkout(user User, item Item) string {
switch err := buyItem(user, item).(type) {
case nil:
return "Заказ оформлен"
case *InsufficientBalanceError:
return fmt.Sprintf("Пополните баланс на %d ₽", err.Diff)
default:
return "Что-то пошло не так, попробуйте позже"
}
}gofmt.Println(checkout(User{Balance: 1000}, Item{Cost: 640}))
fmt.Println(checkout(User{Balance: 100}, Item{Cost: 640}))
fmt.Println(checkout(User{Balance: 600}, Item{Cost: 640}))goЗаказ оформлен
Пополните баланс на 540 ₽
Пополните баланс на 40 ₽Сумма есть и в тексте ошибки, но текст пишется для человека. Достать из него число можно только парсингом строки, и любая правка формулировки сломает вызывающий код. Поле Diff – часть контракта пакета: типизированное значение, от которого код зависит явно.
Как и сравнение через ==, type switch проверяет только саму ошибку: обернутую он не распознает. Замену ему – errors.As – разберем ниже.
Выбор между тремя контрактами сводится к двум вопросам:
Оборачивание ошибок
Обычно ошибка возникает глубоко в стеке вызовов, а решение о ней принимают несколькими вызовами выше. Посмотрим на оформление заказа: checkout покупает каждый товар корзины через buyItem, а потом оплачивает доставку через payDelivery. Обе функции списывают деньги со счета через withdraw:
package stack
const deliveryCost = 400
func withdraw(account *Account, amount int) error {
if account.Balance < amount {
return ErrInsufficientBalance
}
account.Balance -= amount
return nil
}
func buyItem(account *Account, item Item) error {
if err := withdraw(account, item.Cost); err != nil {
return err
}
return nil
}
func payDelivery(account *Account) error {
if err := withdraw(account, deliveryCost); err != nil {
return err
}
return nil
}
func checkout(account *Account, cart []Item) error {
for _, item := range cart {
if err := buyItem(account, item); err != nil {
return err
}
}
if err := payDelivery(account); err != nil {
return err
}
return nil
}goНа счете 1000 ₽, в корзине два товара – за 640 и за 500 ₽. Первый купить удастся, на второй денег не хватит: withdraw вернет ErrInsufficientBalance, и каждая функция выше передаст ту же ошибку дальше:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
fmt.Println(err)goinsufficient balanceДо вершины стека дошло только insufficient balance. Какой товар не удалось купить? С какого счета списывали? buyItem знала, с какого счета списывает деньги, а checkout – какой товар покупает. Но обе вернули ошибку как есть, ничего к ней не добавив.
Хуже того, неясно даже, на каком моменте не хватило денег. Положим в корзину только первый товар: после него на счете останется 360 ₽, а доставка стоит 400 ₽:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}}
err := checkout(account, cart)
fmt.Println(err)goinsufficient balanceУпала другая ветка, а ошибка та же. В языках с исключениями эти два случая различил бы стек-трейс, но ошибка в Go – просто значение, и стека в ней нет: обе ветки вернули один и тот же ErrInsufficientBalance. В реальном сервисе такая общая точка внизу стека – обычно база данных или сеть: десятки разных запросов возвращают одинаковый context deadline exceeded, и по одному тексту не понять, какой из них не уложился во время.
Пусть каждая функция добавит к ошибке то, что знает только она. Для этого у fmt.Errorf есть директива %w – она оборачивает ошибку, дописывая перед ней контекст:
package wrap
import "fmt"
func buyItem(account *Account, item Item) error {
if err := withdraw(account, item.Cost); err != nil {
return fmt.Errorf("withdraw from account %d: %w", account.ID, err)
}
return nil
}
func payDelivery(account *Account) error {
if err := withdraw(account, deliveryCost); err != nil {
return fmt.Errorf("withdraw from account %d: %w", account.ID, err)
}
return nil
}
func checkout(account *Account, cart []Item) error {
for _, item := range cart {
if err := buyItem(account, item); err != nil {
return fmt.Errorf("buy item %d: %w", item.ID, err)
}
}
if err := payDelivery(account); err != nil {
return fmt.Errorf("pay delivery: %w", err)
}
return nil
}goaccount := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
fmt.Println(err)gobuy item 17: withdraw from account 7: insufficient balanceНехватку денег на доставку теперь не спутать с покупкой товара:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}}
err := checkout(account, cart)
fmt.Println(err)gopay delivery: withdraw from account 7: insufficient balanceТеперь строка читается как стек-трейс сверху вниз, только вместо имен функций в ней действия с аргументами – ровно то, что понадобится при разборе инцидента. Каждая функция описывает вызов, который сделала сама: buyItem пишет withdraw from account 7, а checkout допишет buy item 17. Если бы функции подписывали ошибку еще и своим именем, каждая операция попадала бы в строку дважды.
Сравним, что поднимается по стеку в обоих вариантах:
Ту же строку можно собрать и директивой %v. Разница – в том, что вернет fmt.Errorf. С %w ошибка не просто форматируется в строку – fmt.Errorf возвращает структуру wrapError, которая держит ссылку на оригинальную ошибку (поле err):
// fmt/errors.go
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}goОбратите внимание: Error() возвращает просто e.msg, хотя выводится и текст обернутой ошибки. Дело в том, что fmt.Errorf собирает msg в момент создания – текст обернутой ошибки уже подставлен в строку. А достать обернутую ошибку из поля err можно через метод Unwrap(). В пакете errors для этого есть одноименная функция:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
fmt.Println(err)
fmt.Println(errors.Unwrap(err))gobuy item 17: withdraw from account 7: insufficient balance
withdraw from account 7: insufficient balanceСама errors.Unwrap устроена просто: она проверяет type assertion’ом, есть ли у ошибки метод Unwrap(), и вызывает его. Проверка нужна, потому что в интерфейс error этот метод не входит:
// errors/wrap.go
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}goС %v обертки не получится: без %w fmt.Errorf возвращает обычную ошибку из errors.New. Текст тот же, но оригинала ошибки внутри нет – осталась только строка, и errors.Unwrap возвращает nil:
err := fmt.Errorf("withdraw from account %d: %v", 7, ErrInsufficientBalance)
fmt.Println(err)
fmt.Println(errors.Unwrap(err))gowithdraw from account 7: insufficient balance
<nil>Обернутые ошибки можно вкладывать друг в друга – образуется цепочка. В нашем стеке их две, по одной на каждую функцию, которая добавила контекст:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
for err != nil {
fmt.Println(err)
err = errors.Unwrap(err)
}gobuy item 17: withdraw from account 7: insufficient balance
withdraw from account 7: insufficient balance
insufficient balanceКаждый вызов errors.Unwrap снимает одно звено этой цепочки, пока не дойдет до ошибки без метода Unwrap() – там цепочка заканчивается.
Проверка ошибок
errors.Is
Цепочка из ошибок ломает наивную проверку через ==:
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
fmt.Println(err == ErrInsufficientBalance)gofalseОно и понятно: переменная err – это верхнее звено цепочки, у нее тип *wrapError. Мы пытаемся сравнить переменную с ErrInsufficientBalance, у которой тип *errorString. Типы разные, о равенстве речи нет. Нужна проверка, которая умеет заглядывать внутрь цепочки. Здесь на помощь приходит errors.Is:
// errors/wrap.go
func Is(err, target error) bool {
// ...
}goaccount := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}
err := checkout(account, cart)
if errors.Is(err, ErrInsufficientBalance) {
fmt.Println("Top up your funds")
}goTop up your fundserrors.Is снимает звено за звеном через Unwrap() и на каждом шаге сравнивает ошибку с target.
errors.As
С сентинелом errors.Is справляется, потому что ему есть с чем сравнивать: ErrInsufficientBalance – одно значение на всю программу. С кастомной ошибкой так не выйдет. Вернемся к ней – теперь buyItem возвращает ее обернутой:
package check
import "fmt"
func buyItem(user User, item Item) error {
if user.Balance < item.Cost {
return fmt.Errorf("can not buy item: %w", &InsufficientBalanceError{Diff: item.Cost - user.Balance})
}
return nil
}goType switch из оформления заказа такую ошибку больше не узнает:
package check
import "fmt"
func checkoutSwitch(user User, item Item) string {
switch err := buyItem(user, item).(type) {
case nil:
return "Заказ оформлен"
case *InsufficientBalanceError:
return fmt.Sprintf("Пополните баланс на %d ₽", err.Diff)
default:
return "Что-то пошло не так, попробуйте позже"
}
}gofmt.Println(checkoutSwitch(User{Balance: 100}, Item{Cost: 640}))goЧто-то пошло не так, попробуйте позжеType switch смотрит только на верхнее звено цепочки, а у него тип *wrapError. Выполнение уходит в default, и вместо суммы пополнения пользователь видит общую фразу.
Нужна проверка, которая заглянет внутрь цепочки, но errors.Is здесь не подойдет:
err := buyItem(User{Balance: 100}, Item{Cost: 640})
fmt.Println(errors.Is(err, &InsufficientBalanceError{Diff: 540}))gofalsebuyItem создает новую ошибку при каждом вызове, а errors.Is сравнивает указатели. Два указателя на разные структуры не равны, даже если поля совпадают. Да и Diff вызывающий код заранее не знает – ради него ошибку и проверяют. Вопрос нужен другой: не «есть ли в цепочке это значение», а «есть ли в цепочке ошибка этого типа». На него отвечает errors.As:
// errors/wrap.go
func As(err error, target any) bool {
// ...
}goerr := buyItem(User{Balance: 100}, Item{Cost: 640})
var target *InsufficientBalanceError
if errors.As(err, &target) {
fmt.Println(target.Diff)
}go540target – переменная искомого типа, а в As передается ее адрес. Ошибка у нас – указатель *InsufficientBalanceError, поэтому адрес переменной – указатель на указатель, **InsufficientBalanceError. As снимает звено за звеном, а найдя ошибку нужного типа, записывает ее в target по этому адресу и возвращает true. После этого доступны все поля ошибки.
Код оформления заказа снова подсказывает, на сколько пополнить баланс:
package check
import (
"errors"
"fmt"
)
func checkout(user User, item Item) string {
err := buyItem(user, item)
if err == nil {
return "Заказ оформлен"
}
var balanceErr *InsufficientBalanceError
if errors.As(err, &balanceErr) {
return fmt.Sprintf("Пополните баланс на %d ₽", balanceErr.Diff)
}
return "Что-то пошло не так, попробуйте позже"
}gofmt.Println(checkout(User{Balance: 1000}, Item{Cost: 640}))
fmt.Println(checkout(User{Balance: 100}, Item{Cost: 640}))goЗаказ оформлен
Пополните баланс на 540 ₽Искать можно не только конкретный тип, но и интерфейс – то есть поведение. Например, метод Timeout() bool есть у context.DeadlineExceeded, у os.ErrDeadlineExceeded и у сетевых ошибок. Одна проверка находит любую из них, каким бы ни был конкретный тип:
err := fmt.Errorf("get user 42: %w", context.DeadlineExceeded)
var timeout interface{ Timeout() bool }
if errors.As(err, &timeout) {
fmt.Println(timeout.Timeout())
}gotrueЗдесь target – указатель на интерфейс, и As записывает в него первую ошибку цепочки, которая этот интерфейс реализует.
Начиная с Go 1.26, у As есть дженерик-версия – errors.AsType. Искомый тип передается как тип-параметр, а найденная ошибка возвращается значением:
// errors/wrap.go
func AsType[E error](err error) (E, bool) {
// ...
}goerr := buyItem(User{Balance: 100}, Item{Cost: 640})
if target, ok := errors.AsType[*InsufficientBalanceError](err); ok {
fmt.Println(target.Diff)
}go540Переменную не нужно заводить заранее, и найденная ошибка видна только внутри if. Главное же – тип проверяется во время компиляции. As принимает target any и о неправильном таргете узнает только во время выполнения (в корнер кейсах увидим, чем это кончается), а AsType с неподходящим типом просто не скомпилируется.
Искать по поведению через AsType тоже можно, но E ограничен error, поэтому interface{ Timeout() bool } из примера выше не скомпилируется. В интерфейс нужно встроить error: errors.AsType[interface{ error; Timeout() bool }](err).
Кастомные Is и As
Под капотом errors.Is не ограничивается сравнением: если у ошибки в цепочке есть метод Is(target error) bool, он тоже участвует в проверке. Так ошибка сама решает, чему она «равна». Например, можно объявить, что любая *InsufficientBalanceError соответствует сентинелу ErrInsufficientBalance:
package check
import "errors"
// Is сообщает errors.Is, что любая *InsufficientBalanceError
// соответствует сентинелу ErrInsufficientBalance
func (e *InsufficientBalanceError) Is(target error) bool {
return target == ErrInsufficientBalance
}goerr := buyItem(User{Balance: 100}, Item{Cost: 640})
fmt.Println(errors.Is(err, ErrInsufficientBalance))gotrueБез метода Is эта проверка вернула бы false: в цепочке лежит *InsufficientBalanceError, а не сам сентинел. С методом – клиенты могут проверять оба вида ошибки одной строкой.
Важно, что мы сравниваем target с конкретной переменной, а не с текстом: сравнение target.Error() == "insufficient balance" завязало бы проверку на строку, которая может измениться, и «подделать» ее мог бы кто угодно, как мы видели в разделе про указатели.
По аналогии кастомная ошибка может объявить метод As(target any) bool – тогда errors.As спросит у нее, подходит ли она под тип target, и метод сам запишет значение в target. Нужен он редко, и в своем коде вы его, скорее всего, не встретите.
Как Is и As обходят цепочку
Заглянем в сам errors.Is:
// errors/wrap.go
func is(err, target error, targetComparable bool) bool {
for {
if targetComparable && err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil {
return false
}
case interface{ Unwrap() []error }:
for _, err := range x.Unwrap() {
if is(err, target, targetComparable) {
return true
}
}
return false
default:
return false
}
}
}goНа каждом шаге цикла is сравнивает текущую ошибку с target и вызывает ее метод Is, если он есть. Если ни то, ни другое не сработало, функция снимает одну обертку через Unwrap и повторяет проверки. Цепочка кончается, когда Unwrap вернул nil или у ошибки нет такого метода.
Второй case нужен для ошибки, которая оборачивает сразу несколько ошибок: ее Unwrap возвращает []error. Тогда is вызывает себя для каждой из них по очереди. Как получить такую ошибку, разберем в следующем разделе.
Мульти-ошибки
Иногда ошибок несколько по самой природе задачи: валидация нашла три проблемы, из пяти горутин упали две, при откате транзакции добавилась ошибка закрытия соединения. Начиная с Go 1.20, для этого есть инструмент – errors.Join:
package multi
import "errors"
var (
ErrNameEmpty = errors.New("name is empty")
ErrEmailInvalid = errors.New("email is invalid")
)
type User struct {
Name string
Email string
}
func validate(user User) error {
var errs []error
if user.Name == "" {
errs = append(errs, ErrNameEmpty)
}
if user.Email == "" {
errs = append(errs, ErrEmailInvalid)
}
return errors.Join(errs...)
}goerr := validate(User{})
fmt.Println(err)goname is empty
email is invalidТекст объединенной ошибки – тексты всех ошибок с переносом строк. errors.Join отбрасывает nil-аргументы, а если все аргументы nil – возвращает nil, поэтому validate не нужно отдельно обрабатывать случай «ошибок нет»:
err := validate(User{Name: "Jack", Email: "jack@example.com"})
fmt.Println(err == nil)gotrueПод капотом Join создает joinError – слайс ошибок с тем самым методом Unwrap() []error:
// errors/join.go
type joinError struct {
errs []error
}
func (e *joinError) Unwrap() []error {
return e.errs
}goВот как выглядит в памяти ошибка, которую вернула validate(User{}):
errors.Is проверяет каждую ошибку из слайса:
err := validate(User{Name: "Jack"})
fmt.Println(errors.Is(err, ErrNameEmpty))
fmt.Println(errors.Is(err, ErrEmailInvalid))gofalse
trueВторой способ получить мульти-ошибку – несколько директив %w в одном fmt.Errorf:
err := fmt.Errorf("validate user: %w, %w", ErrNameEmpty, ErrEmailInvalid)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNameEmpty), errors.Is(err, ErrEmailInvalid))govalidate user: name is empty, email is invalid
true trueПока Unwrap возвращает одну ошибку, обертки выстраиваются в цепочку. Мульти-ошибка ее ветвит, а каждая ветка сама может оказаться цепочкой оберток или еще одной мульти-ошибкой – получается дерево ошибок, так его и называет документация пакета errors. Соберем такое дерево и спрячем в одну из веток кастомную ошибку – errors.As найдет ее рекурсивным обходом:
package multi
import "fmt"
type FieldError struct {
Field string
}
func (e *FieldError) Error() string {
return fmt.Sprintf("field %q is invalid", e.Field)
}goleaf := &FieldError{Field: "email"}
left := fmt.Errorf("check profile: %w", leaf)
right := errors.New("check quota: limit exceeded")
err := errors.Join(left, right)
var target *FieldError
fmt.Println(errors.As(err, &target))
fmt.Println(target.Field)gotrue
emailAs обходит дерево в глубину и останавливается на первой подходящей ошибке: до errorString в правой ветке дело не доходит. Если бы в обеих ветках лежало по *FieldError, в target попала бы ошибка из левой – порядок аргументов Join решает, какую из них увидит вызывающий код.
Хорошие практики
Транслируйте ошибки на границе пакета
Оборачивание с %w делает ошибку частью API: клиенты смогут проверять ее через errors.Is и errors.As. Посмотрим на функцию, которая получает пользователя из SQL-базы:
package boundary
import (
"database/sql"
"fmt"
)
// GetLeaky оборачивает ошибку драйвера и отдает ее наружу
func GetLeaky(db *sql.DB, id UserID) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
row := db.QueryRow(query, id)
var user User
if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &user, nil
}goЕсли записи нет, row.Scan вернет sql.ErrNoRows, обертка сохранит ее в цепочке – и клиентский код завяжется на нее:
package boundary
import (
"database/sql"
"errors"
)
// renderUserLeaky зависит от sql.ErrNoRows – детали реализации хранилища
func renderUserLeaky(db *sql.DB, id UserID) string {
user, err := GetLeaky(db, id)
if errors.Is(err, sql.ErrNoRows) {
return "user not found"
}
if err != nil {
return "internal error"
}
return user.Name
}goТеперь то, что пользователи лежат в SQL-базе, – не деталь реализации, а контракт: замена хранилища на кэш или внешний сервис сломает всех, кто проверяет sql.ErrNoRows.
Решение – объявить собственную ошибку на границе пакета и транслировать в нее ошибки нижнего слоя:
package boundary
import "errors"
var ErrUserNotFound = errors.New("user not found")gopackage boundary
import (
"database/sql"
"errors"
"fmt"
)
// Get переводит ошибку драйвера в сентинел пакета
func Get(db *sql.DB, id UserID) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
row := db.QueryRow(query, id)
var user User
if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &user, nil
}gopackage boundary
import (
"database/sql"
"errors"
)
// renderUser зависит только от контракта пакета с юзерами
func renderUser(db *sql.DB, id UserID) string {
user, err := Get(db, id)
if errors.Is(err, ErrUserNotFound) {
return "user not found"
}
if err != nil {
return "internal error"
}
return user.Name
}goКлиент зависит только от ErrUserNotFound, а хранилище можно менять свободно. Заметьте: непредвиденные ошибки Get по-прежнему оборачивает с %w – они не часть контракта, и клиент не должен на них завязываться, но текст и цепочка пригодятся в логах.
Есть и промежуточный вариант: добавить контекст, не включая ошибку в цепочку. Здесь пригодится уже знакомый %v – текст сохранится, но errors.Is исходную ошибку больше не увидит:
errW := fmt.Errorf("load config: %w", fs.ErrNotExist)
errV := fmt.Errorf("load config: %v", fs.ErrNotExist)
fmt.Println(errW, "–", errors.Is(errW, fs.ErrNotExist))
fmt.Println(errV, "–", errors.Is(errV, fs.ErrNotExist))goload config: file does not exist – true
load config: file does not exist – falseОбрабатывайте ошибку один раз
Получив ошибку, функция либо обрабатывает ее – логирует, повторяет попытку, – либо возвращает выше. Что-то одно. Самое частое нарушение – «залогировать и вернуть»:
package handle
import "log"
// payNoisy обрабатывает ошибку дважды: логирует и возвращает
func payNoisy() error {
if err := chargeCard(); err != nil {
log.Printf("charge card: %v", err)
return err
}
return nil
}goКаждая функция в стеке, которая так делает, логирует одну и ту же ошибку еще раз:
mux := http.NewServeMux()
mux.HandleFunc("POST /orders/{id}/checkout", handleCheckoutNoisy)
req := httptest.NewRequest(http.MethodPost, "/orders/42/checkout", nil)
mux.ServeHTTP(httptest.NewRecorder(), req)gocharge card: card declined
pay for order 42: card declined
checkout: card declinedОдна неудачная оплата сгенерировала три записи в логе. В большом сервисе это превращает лог в шум, по которому невозможно понять, сколько инцидентов произошло на самом деле.
Если функция возвращает ошибку, она ее не обрабатывает, а оборачивает и отдает дальше. Обрабатывает функция выше по стеку – здесь HTTP-обработчик:
package handle
import (
"fmt"
"log"
"net/http"
)
// pay не обрабатывает ошибку, а оборачивает и отдает дальше
func pay() error {
if err := chargeCard(); err != nil {
return fmt.Errorf("charge card: %w", err)
}
return nil
}
func checkout(orderID string) error {
if err := pay(); err != nil {
return fmt.Errorf("pay for order %s: %w", orderID, err)
}
return nil
}
// handleCheckout обрабатывает ошибку: логирует ее и отвечает клиенту
func handleCheckout(w http.ResponseWriter, r *http.Request) {
if err := checkout(r.PathValue("id")); err != nil {
log.Printf("checkout: %v", err)
http.Error(w, "checkout failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}gomux := http.NewServeMux()
mux.HandleFunc("POST /orders/{id}/checkout", handleCheckout)
req := httptest.NewRequest(http.MethodPost, "/orders/42/checkout", nil)
mux.ServeHTTP(httptest.NewRecorder(), req)gocheckout: pay for order 42: charge card: card declinedВ логе одна строка, и по ней видно, что произошло: заказ 42 не оформился, потому что при оплате банк отклонил карту.
Стиль сообщений об ошибках
Текст ошибки почти всегда оказывается внутри чужого текста – после двоеточия в обертке, в строке лога, в выводе CLI. Отсюда два правила из Go code review comments ↗: сообщение начинается со строчной буквы и не заканчивается знаком препинания – fmt.Errorf("something bad"), а не fmt.Errorf("Something bad!").
В Uber Go Style Guide ↗ есть еще одно полезное правило: не начинайте сообщение с failed to. В цепочке оберток эти слова повторяются на каждом уровне:
err := errors.New("failed to connect to database")
err = fmt.Errorf("failed to load users: %w", err)
err = fmt.Errorf("failed to handle request: %w", err)
fmt.Println(err)gofailed to handle request: failed to load users: failed to connect to databaseСам факт неудачи уже очевиден из того, что ошибка существует. Достаточно зафиксировать действие, во время которого она произошла:
err := errors.New("connection refused")
err = fmt.Errorf("connect to database: %w", err)
err = fmt.Errorf("load users: %w", err)
fmt.Println(err)goload users: connect to database: connection refusedКорнер кейсы
errors.Is(nil, nil) возвращает true. Два nil считаются равными:
fmt.Println(errors.Is(nil, nil))gotrueПри этом ненулевая ошибка с nil-таргетом дает false:
err := errors.New("boom")
fmt.Println(errors.Is(err, nil))gofalseerrors.As паникует на неправильном таргете. Если передать не указатель на тип, реализующий error, и не указатель на интерфейс – будет паника, а не false. Обычно такие вызовы ловит статически проверка errorsas из go vet, поэтому в примере тип спрятан за any:
defer func() {
fmt.Println(recover())
}()
err := errors.New("boom")
var s string
target := any(&s)
errors.As(err, target)goerrors: *target must be interface or implement errorУ errors.AsType этой ловушки нет: неподходящий тип отсеет компилятор.
%w с nil-ошибкой возвращает не-nil ошибку. Соблазнительно написать return fmt.Errorf("context: %w", err) без проверки err – но если err равен nil, результат все равно будет ошибкой:
var err error
wrapped := fmt.Errorf("request failed: %w", err)
fmt.Println(wrapped)
fmt.Println(wrapped == nil)
fmt.Println(errors.Unwrap(wrapped))gorequest failed: %!w(<nil>)
false
<nil>fmt.Errorf все равно создает обертку *wrapError, но ее поле err остается nil: в тексте появляется %!w(<nil>), а errors.Unwrap возвращает nil. Оборачивайте только ненулевые ошибки.