Обработка ошибок в Go концептуально прозрачна но вызывает массу вопросов и негодования у людей переходящих на Go с ООП языков. И первое с чем такие люди сталкиваются это отсутствие вложенности ошибок. Чтобы как-то смягчить эту боль в заднице Dave Cheney написал библиотеку для оборачивания ошибок и реализуя некое подобие наследования.

К версии 1.13 разработчики устали слушать нытье и насыпали немного синтаксического сахара добавив новую структуру для оборачивания ошибок и две новых функции для работы с ошибками. Вот как это работает под капотом. Когда мы создаем обычную ошибку, то фактически инициализируем следующую структуру.

type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

А если оборачиваем ошибку используя вызов fmt.Errorf("%w", err), то возвращаемая ошибка получит уже другую структуру, содержащую указатель на оборачиваемую ошибку и метод Unwrap() для доступа к ней.

type wrapError struct {
	msg string
	err error
}

func (e *wrapError) Error() string {
	return e.msg
}

func (e *wrapError) Unwrap() error {
	return e.err
}

С такими ошибками можно делать ровно два действия, сравнить со значением и с типом (не совсем). Понять как это работает проще всего на примере. Возьмем абстрактный микросервис, которых ходит в другой микросервис через RPC клиент по http.

Handler → Middleware → RPC Client → HTTP Client

Ошибка может произойти как на уровне http клиента, так и в постобработке полученного ответа. При этом логирование ошибок происходит на уровне handler’а, а остальные уровни только оборачивают ошибки и пробрасывают наверх. Хватит теории, давай код.

package main

import (
	"fmt"
	"net/http"
)

type httpError struct {
	Code    int
	Message string
}

func (e *httpError) Error() string {
	return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

type rpcError struct {
	Method string
	err    error
}

func (e *rpcError) Error() string {
	return fmt.Sprintf("%s error: %s", e.Method, e.err.Error())
}

func (e *rpcError) Unwrap() error {
	return e.err
}

func main() {
	httpErr := &httpError{
		Code:    http.StatusInternalServerError,
		Message: http.StatusText(http.StatusInternalServerError),
	}
	rpcErr := &rpcError{
		Method: "getEnterpriseResourceV1",
		err:    httpErr,
	}
	middlewareErr := fmt.Errorf("middleware: %w", rpcErr)
	handlerErr := fmt.Errorf("handler: %w", middlewareErr)

	fmt.Printf("%q\n", handlerErr)
	// handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error
}

Тут происходит имитация ошибки на уровне http клиента с последующим оборачиванием в rpc ошибку, а потом в middleware и handler. Конечная ошибка выглядит как набор вложенных друг в друга структур.

fmt.wrapError{
    msg: "handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error",
    err: &fmt.wrapError{
        msg: "middleware: getEnterpriseResourceV1 error: 500: Internal Server Error",
        err: &rpcError{
            Method: "getEnterpriseResourceV1",
            err: &httpError{
                Code:    500,
                Message: "Internal Server Error",
            },
        },
    },
}

Имея такую матрешку попробуем выяснить какая ошибка была изначально и какие этапы оборачивания она прошла. Для этого воспользуемся функцией сравнения ошибок по значению errors.Is(err, target error). Это функция рекурсивно сравнивает значение ошибки с target вызывая .Unwrap() для продвижения вглубь матрешки.

errors.Is(handlerErr, handlerErr)
// true т.к эквивалент ==
errors.Is(handlerErr, middlewareErr)
// true т.к handlerErr.Unwrap() == middlewareErr
errors.Is(handlerErr, rpcErr)
// true т.к handlerErr.Unwrap().Unwrap() == rpcErr
errors.Is(handlerErr, httpErr)
// true т.к handlerErr.Unwrap().Unwrap().Unwrap() == httpErr

errors.Is(handlerErr, fmt.Errorf("generic")) // false
errors.Is(handlerErr, &unknownError{})       // false
errors.Is(handlerErr, &rpcError{})           // false т.к совпадает тип, но не значение
errors.Is(handlerErr, &httpError{})          // false т.к совпадает тип, но не значение

Выглядит логично и удобно, но в реальном мире это не будет работать. Переменные вложенных ошибок будут в другом скоупе и их невозможно будет достать чтобы сравнить. Правда можно добавить немного магии, реализовав метод Is(target error) у httpError.

func (e *httpError) Is(target error) bool {
	return reflect.ValueOf(target).Elem().Type() == reflect.TypeOf(httpError{})
}

В таком случае сработает проверка по-типу даже если значения не будут совпадать.

errors.Is(handlerErr, &httpError{})
// true

Но это хак, т.к для проверки по типу в Go добавили вторую функцию errors.As(err error, target interface{}). Она работает аналогично функции errors.Is, рекурсивно обходя матрешку, но сравнивает не значения, а тип target и вызывая .Unwrap() для продвижения вглубь. Если же совпадение найдено, но значения типа совпавшей ошибки копируется в target.

var s *rpcError
if errors.As(handlerErr, &s) {
    fmt.Printf("handlerErr as rpcError: %s\n", s.Method)
}
// handlerErr as rpcError: getEnterpriseResourceV1

var h *httpError
if errors.As(handlerErr, &h) {
    fmt.Printf("handlerErr as httpError: %d\n", h.Code)
}
// handlerErr as httpError: 500

var e error
if errors.As(handlerErr, &e) {
    fmt.Printf("handlerErr as error: %s\n", e)
}
// handlerErr as error: handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error

var u *unknownError
if errors.As(handlerErr, &u) {
    fmt.Printf("handlerErr as unknownError: %v\n", u)
}
// не выведет ничего

У такого подхода один очевидный плюс, разработчики убили сразу двух зайцев, и сравнение по типу и распаковка ошибки. Но есть и минусы, в ситуации когда нужно просто понять что за тип ошибки нет варианта использовать конструкцию switch, как это можно сделать используя пакет pkg/errors.

В целом новый механизм работы с ошибками решает часть болей и потенциально помогает отказаться от еще одной внешней зависимости в проекте.