BackendBit

К статьям о 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)
}
go
insufficient 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)
go
false
false

Две ошибки с одинаковым текстом не равны. Причина – в том, что New возвращает указатель *errorString. При сравнении интерфейсных значений сравниваются динамические типы и динамические значения, а два указателя равны, только когда указывают на один и тот же объект в памяти.

Именно это свойство делает сентинелы безопасными. Если бы New возвращал структуру по значению, любой пакет мог бы «подделать» вашу ошибку, создав ошибку с тем же текстом. С указателем совпадение возможно только с самой переменной-сентинелом:

err := buyItem(User{Balance: 100}, Item{Cost: 640})

fmt.Println(err == ErrInsufficientBalance)
fmt.Println(err == errors.New("insufficient balance"))
go
true
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)
go
parse 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")
)
go
package 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 "Что-то пошло не так, попробуйте позже"
	}
}
go
fmt.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)
}
go
package 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 "Что-то пошло не так, попробуйте позже"
	}
}
go
fmt.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 – разберем ниже.

Выбор между тремя контрактами сводится к двум вопросам:

Блок-схема выбора способа создания ошибки в GoЕсли вызывающему коду не нужно различать ошибку – создавайте непрозрачную ошибку через fmt.Errorf. Если различать нужно, но данные об ошибке не нужны – заведите сентинел через errors.New на уровне пакета. Если нужны и данные – объявите кастомный тип с методом Error().Создание ошибкиНужно ли различатьэту ошибку?Непрозрачная ошибкаfmt.Errorf(...)Нужны ли данныеоб ошибке?Сентинелerrors.New(...)Кастомный типс методом Error()НетДаНетДа

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

Обычно ошибка возникает глубоко в стеке вызовов, а решение о ней принимают несколькими вызовами выше. Посмотрим на оформление заказа: 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)
go
insufficient 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)
go
insufficient 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
}
go
account := &Account{ID: 7, Balance: 1000}
cart := []Item{{ID: 3, Cost: 640}, {ID: 17, Cost: 500}}

err := checkout(account, cart)

fmt.Println(err)
go
buy 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)
go
pay delivery: withdraw from account 7: insufficient balance

Теперь строка читается как стек-трейс сверху вниз, только вместо имен функций в ней действия с аргументами – ровно то, что понадобится при разборе инцидента. Каждая функция описывает вызов, который сделала сама: buyItem пишет withdraw from account 7, а checkout допишет buy item 17. Если бы функции подписывали ошибку еще и своим именем, каждая операция попадала бы в строку дважды.

Сравним, что поднимается по стеку в обоих вариантах:

Ошибка поднимается по стеку вызовов без оборачивания и с оборачиваниемДва стека вызовов рядом: checkout вызывает buyItem, buyItem вызывает withdraw. Ошибка возникает в withdraw и поднимается вверх. Слева каждая функция возвращает ошибку как есть, и из каждой выходит один и тот же текст "insufficient balance". Справа каждая функция оборачивает ошибку через %w: withdraw возвращает "insufficient balance", buyItem – "withdraw from account 7: insufficient balance", checkout – "buy item 17: withdraw from account 7: insufficient balance".checkoutbuyItemwithdrawcheckoutbuyItemwithdrawreturn errfmt.Errorf("…: %w", err)insufficient balanceinsufficient balanceinsufficient balancebuy item 17:withdraw from account 7:insufficient balancewithdraw from account 7:insufficient balanceinsufficient balance

Ту же строку можно собрать и директивой %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))
go
buy 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))
go
withdraw 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)
}
go
buy item 17: withdraw from account 7: insufficient balance
withdraw from account 7: insufficient balance
insufficient balance
Цепочка обернутых ошибокЦепочка из трех структур в памяти. Переменная err указывает на *wrapError, который вернула checkout: поле msg – "buy item 17: withdraw from account 7: insufficient balance", поле err – указатель. Unwrap() идет по этому указателю к *wrapError, который вернула buyItem: msg – "withdraw from account 7: insufficient balance", err – указатель на *errorString, который вернула withdraw, с полем s "insufficient balance". На ту же *errorString указывает переменная ErrInsufficientBalance. В каждом msg выделена часть, которую дописал этот слой, а остальное – копия текста из нижнего слоя. У *errorString метода Unwrap() нет, и errors.Unwrap возвращает nil – цепочка заканчивается.*wrapErrormsg"buy item 17: withdraw from account 7: insufficient balance"err*wrapErrormsg"withdraw from account 7: insufficient balance"err*errorStrings"insufficient balance"вернула checkoutвернула buyItemвернула withdrawerrErrInsufficientBalanceUnwrap()Unwrap()Unwrap() нетnil

Каждый вызов 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)
go
false

Оно и понятно: переменная err – это верхнее звено цепочки, у нее тип *wrapError. Мы пытаемся сравнить переменную с ErrInsufficientBalance, у которой тип *errorString. Типы разные, о равенстве речи нет. Нужна проверка, которая умеет заглядывать внутрь цепочки. Здесь на помощь приходит errors.Is:

// errors/wrap.go

func Is(err, target error) bool {
	// ...
}
go
account := &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")
}
go
Top up your funds

errors.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
}
go

Type 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 "Что-то пошло не так, попробуйте позже"
	}
}
go
fmt.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}))
go
false

buyItem создает новую ошибку при каждом вызове, а errors.Is сравнивает указатели. Два указателя на разные структуры не равны, даже если поля совпадают. Да и Diff вызывающий код заранее не знает – ради него ошибку и проверяют. Вопрос нужен другой: не «есть ли в цепочке это значение», а «есть ли в цепочке ошибка этого типа». На него отвечает errors.As:

// errors/wrap.go

func As(err error, target any) bool {
	// ...
}
go
err := buyItem(User{Balance: 100}, Item{Cost: 640})

var target *InsufficientBalanceError
if errors.As(err, &target) {
	fmt.Println(target.Diff)
}
go
540

target – переменная искомого типа, а в 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 "Что-то пошло не так, попробуйте позже"
}
go
fmt.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())
}
go
true

Здесь target – указатель на интерфейс, и As записывает в него первую ошибку цепочки, которая этот интерфейс реализует.

Начиная с Go 1.26, у As есть дженерик-версия – errors.AsType. Искомый тип передается как тип-параметр, а найденная ошибка возвращается значением:

// errors/wrap.go

func AsType[E error](err error) (E, bool) {
	// ...
}
go
err := buyItem(User{Balance: 100}, Item{Cost: 640})

if target, ok := errors.AsType[*InsufficientBalanceError](err); ok {
	fmt.Println(target.Diff)
}
go
540

Переменную не нужно заводить заранее, и найденная ошибка видна только внутри 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
}
go
err := buyItem(User{Balance: 100}, Item{Cost: 640})

fmt.Println(errors.Is(err, ErrInsufficientBalance))
go
true

Без метода 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...)
}
go
err := validate(User{})

fmt.Println(err)
go
name is empty
email is invalid

Текст объединенной ошибки – тексты всех ошибок с переносом строк. errors.Join отбрасывает nil-аргументы, а если все аргументы nil – возвращает nil, поэтому validate не нужно отдельно обрабатывать случай «ошибок нет»:

err := validate(User{Name: "Jack", Email: "jack@example.com"})

fmt.Println(err == nil)
go
true

Под капотом Join создает joinError – слайс ошибок с тем самым методом Unwrap() []error:

// errors/join.go

type joinError struct {
	errs []error
}

func (e *joinError) Unwrap() []error {
	return e.errs
}
go

Вот как выглядит в памяти ошибка, которую вернула validate(User{}):

Дерево ошибок, которое вернул errors.JoinПеременная err указывает на *joinError, который вернула errors.Join. У него одно поле errs – слайс. Unwrap() []error возвращает этот слайс: его массив из двух элементов [2]error. Элемент [0] указывает на *errorString с полем s "name is empty", элемент [1] – на *errorString с полем s "email is invalid". На те же *errorString указывают переменные ErrNameEmpty и ErrEmailInvalid. Из одной ошибки выходят две ветки – цепочка ветвится и становится деревом.*joinErrorerrs[2]error[0][1]*errorStrings"name is empty"*errorStrings"email is invalid"вернула errors.Joinмассив слайса errserrUnwrap() []errorErrNameEmptyErrEmailInvalid

errors.Is проверяет каждую ошибку из слайса:

err := validate(User{Name: "Jack"})

fmt.Println(errors.Is(err, ErrNameEmpty))
fmt.Println(errors.Is(err, ErrEmailInvalid))
go
false
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))
go
validate 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)
}
go
leaf := &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)
go
true
email
Как errors.As обходит дерево ошибок, созданное errors.JoinКорень дерева – *joinError с текстом из двух строк: "check profile: field "email" is invalid" и "check quota: limit exceeded". Левая ветка – *wrapError с текстом "check profile: field "email" is invalid", которая оборачивает *FieldError с текстом "field "email" is invalid". Правая ветка – *errorString с текстом "check quota: limit exceeded". errors.As обходит дерево в глубину: первым проверяет *joinError, вторым *wrapError, третьим *FieldError – и на нем останавливается. До правой ветки поиск не доходит.*joinErrorcheck profile: field "email" is invalidcheck quota: limit exceeded*wrapErrorcheck profile: field "email" is invalid*errorStringcheck quota: limit exceeded*FieldErrorfield "email" is invalid123поиск сюда не дошелОбход в глубину: перваяподходящая ошибкаостанавливает поиск

As обходит дерево в глубину и останавливается на первой подходящей ошибке: до 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")
go
package 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
}
go
package 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))
go
load 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)
go
charge 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)
}
go
mux := http.NewServeMux()
mux.HandleFunc("POST /orders/{id}/checkout", handleCheckout)

req := httptest.NewRequest(http.MethodPost, "/orders/42/checkout", nil)
mux.ServeHTTP(httptest.NewRecorder(), req)
go
checkout: 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)
go
failed 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)
go
load users: connect to database: connection refused

Корнер кейсы

errors.Is(nil, nil) возвращает true. Два nil считаются равными:

fmt.Println(errors.Is(nil, nil))
go
true

При этом ненулевая ошибка с nil-таргетом дает false:

err := errors.New("boom")

fmt.Println(errors.Is(err, nil))
go
false

errors.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)
go
errors: *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))
go
request failed: %!w(<nil>)
false
<nil>

fmt.Errorf все равно создает обертку *wrapError, но ее поле err остается nil: в тексте появляется %!w(<nil>), а errors.Unwrap возвращает nil. Оборачивайте только ненулевые ошибки.