Обработка ошибок в Go: от if err != nil до errors.Join

В Go ошибки — это значения. Не исключения, не коды возврата в стиле C, не паника. Обычные значения, которые передаются, проверяются и трансформируются по тем же правилам, что и любые другие данные. Это сознательное решение авторов языка, которое меняет подход к написанию кода: вместо try-catch блоков, где обработка ошибок живёт отдельно от основного потока, в Go ошибка обрабатывается в том же месте, где возникает.

На первый взгляд это выглядит многословно. Причём настолько что if err != nil стало мемом не только в Go сообществе, но и за его пределами. Однако за всем стоит система, которая при правильном использовании даёт понятные сообщения об ошибках, удобную диагностику и предсказуемое поведение. В этой статье разберём, как устроена обработка ошибок в Go и какие паттерны помогают писать надёжный код.

Интерфейс error

Всё начинается с интерфейса:

type error interface {
    Error() string
}

Любой тип, у которого есть метод Error() string, является ошибкой. Стандартная библиотека предоставляет два основных способа создать ошибку:

// Через errors.New для статических сообщений
err := errors.New("файл не найден")

// Через fmt.Errorf для сообщений с параметрами
err := fmt.Errorf("файл %s не найден", filename)

errors.New возвращает указатель на простую структуру с единственным строковым полем . fmt.Errorf делает то же самое, но с форматированием. Эти два способа покрывают большинство случаев.

Возврат и проверка ошибок

В Go принято возвращать ошибку как последнее значение из функции:

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("чтение конфига: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("парсинг конфига: %w", err)
    }

    return &cfg, nil
}

Вызывающий код проверяет ошибку:

cfg, err := ReadConfig("/etc/app/config.json")
if err != nil {
    log.Fatalf("не удалось загрузить конфигурацию: %v", err)
}

Частые if err != nil многословны. Зато каждая ошибка обрабатывается явно, и при чтении кода сразу видно, что происходит при сбое. В языках с исключениями при чтении функции из 20 строк непонятно, какая из них может бросить исключение и куда управление уйдёт. В Go это всегда явно.

Оборачивание ошибок: fmt.Errorf и %w

Начиная с Go 1.13, fmt.Errorf поддерживает глагол %w, который оборачивает ошибку. Оборачивание сохраняет оригинальную ошибку внутри новой, добавляя больше описания ошибке:

func (s *Storage) GetUser(ctx context.Context, id int64) (*User, error) {
    row := s.db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)

    var u User
    if err := row.Scan(&u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("получение пользователя id=%d: %w", id, err)
    }

    return &u, nil
}

Если row.Scan вернёт sql.ErrNoRows, то GetUser вернёт ошибку вида:

получение пользователя id=42: sql: no rows in result set

При этом оригинальная sql.ErrNoRows сохранена внутри и доступна через errors.Is. Без %w (если использовать %v) контекст добавится, но связь с оригинальной ошибкой потеряется. Т.к. произойдет форматирование значения (глагол “%v” значит value, т.е. значение), а не оборачивание (глагол “%w” значит warp, т.е. оборачивать).

Каждый уровень оборачивания ошибки добавляет свой контекст. В итоге ошибка, дошедшая до верхнего уровня, содержит полный путь:

обработка заказа: получение пользователя id=42: sql: no rows in result set

По этому сообщению сразу понятно, что произошло, где и почему. А так же мы не теряем изначальную ошибку и сохраняем возможность проверки на неё через errors.Is.

Sentinel-ошибки и errors.Is

Sentinel-ошибки это заранее определённые значения ошибок на уровне пакета. В стандартной библиотеке таких примеров много:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("conflict")
)

Для проверки sentinel-ошибок используется errors.Is. Эта функция проходит по всей цепочке оборачиваний и проверяет совпадение:

user, err := storage.GetUser(ctx, userID)
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    return nil, fmt.Errorf("получение пользователя: %w", err)
}

errors.Is работает рекурсивно, если ошибка обёрнута через %w в несколько слоёв, она всё равно найдёт совпадение. Поэтому прямое сравнение err == sql.ErrNoRows является ошибкой. Сравнение сработает только если ошибка не обёрнута, а это хрупкое допущение: любой промежуточный слой может добавить обёртку, и сравнение сломается.

// Плохо: не найдёт ошибку, если она обёрнута
if err == sql.ErrNoRows {
    // ...
}

// Хорошо: проходит по всей цепочке
if errors.Is(err, sql.ErrNoRows) {
    // ...
}

Типизированные ошибки и errors.As

Когда недостаточно просто знать, что ошибка произошла и нужны детали. этого создают типизированные ошибки Иногда кроме факта ошибки нужно больше подробностей, и тогда можно воспользоваться типизированными ошибками.

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("валидация поля %s: %s", e.Field, e.Message)
}

errors.As извлекает типизированную ошибку из цепочки:

var ve *ValidationError
if errors.As(err, &ve) {
    // Доступны поля ve.Field и ve.Message
    log.Printf("ошибка валидации: поле %s — %s", ve.Field, ve.Message)
}

Как и errors.Is, функция errors.As рекурсивно проходит по всей цепочке обёрток. Типичное встречается в HTTP обработчиках, где тип ошибки определяет код ответа:

func handleError(w http.ResponseWriter, err error) {
    var ve *ValidationError
    if errors.As(err, &ve) {
        http.Error(w, ve.Error(), http.StatusBadRequest)
        return
    }

    if errors.Is(err, ErrNotFound) {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    if errors.Is(err, ErrUnauthorized) {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    // Неизвестная ошибка, код 500
    log.Printf("внутренняя ошибка: %v", err)
    http.Error(w, "internal server error", http.StatusInternalServerError)
}

Когда использовать sentinel, а когда типы

Sentinel-ошибки подходят, когда важен сам факт ошибки: «не найдено», «нет доступа», «конфликт версий». Типизированные ошибки важны, когда вместе с фактом ошибки нужны данные: какое поле не прошло валидацию, какой лимит превышен, какой ресурс заблокирован.

errors.Join: несколько ошибок сразу

Начиная с Go 1.20 появилась функция errors.Join, которая объединяет несколько ошибок в одну:

func validateOrder(o Order) error {
    var errs []error

    if o.CustomerID == 0 {
        errs = append(errs, fmt.Errorf("не указан customer_id"))
    }
    if len(o.Items) == 0 {
        errs = append(errs, fmt.Errorf("заказ без товаров"))
    }
    if o.Total <= 0 {
        errs = append(errs, fmt.Errorf("некорректная сумма: %d", o.Total))
    }

    return errors.Join(errs...)
}

errors.Join вернёт nil, если все ошибки в слайсе nil. Это удобно: не нужно отдельно проверять, были ли ошибки.

errors.Is и errors.As так же работают и с объединёнными ошибками через проверку каждой ошибки в группе:

err := errors.Join(ErrNotFound, ErrConflict)

errors.Is(err, ErrNotFound) // true
errors.Is(err, ErrConflict) // true

Типично используется при валидации, где нужно собрать все ошибки, а не останавливаться на первой. Или закрытие нескольких ресурсов, где каждый может вернуть ошибку:

func (app *App) Close() error {
    return errors.Join(
        app.db.Close(),
        app.cache.Close(),
        app.queue.Close(),
    )
}

Практические паттерны

Добавляйте контекст при оборачивании

Голая ошибка без контекста бесполезна в логах:

// Очень Плохо: потеряна ошибка, контекст не добавляет понимания
return errors.New("ошибка")

// Плохо: непонятно, откуда ошибка
return err

// Плохо: контекст не добавляет информации
return fmt.Errorf("ошибка: %w", err)

// Хорошо: понятно, что делали и с какими параметрами
return fmt.Errorf("отправка уведомления пользователю %d: %w", userID, err)

Контекст должен отвечать на вопросы «что делали» и «с какими данными». Не дублируйте информацию, которая уже есть в оригинальной ошибке.

Обрабатывайте ошибку один раз

Ошибку нужно либо обработать (залогировать, вернуть пользователю, повторить операцию), либо передать выше. Не делайте и то и другое:

// Плохо: ошибка логируется здесь и ещё раз выше
if err != nil {
    log.Printf("ошибка: %v", err)
    return fmt.Errorf("операция не удалась: %w", err)
}

// Хорошо: просто передаём выше с контекстом
if err != nil {
    return fmt.Errorf("операция не удалась: %w", err)
}

Если залогировали и вернули, то одна и та же ошибка появится в логах дважды (или ещё больше, если вызывающие тоже логируют). На проде с высоким RPS это превращает логи в поток лишней информации.

Не оборачивайте, если контекст не нужен

Если функция делает одно действие и из контекста и так понятно, что произошло, дополнительная обёртка добавляет шум:

// Лишняя обёртка: и так понятно, что ping не прошёл
func (s *Storage) Ping(ctx context.Context) error {
    if err := s.db.PingContext(ctx); err != nil {
        return fmt.Errorf("ping базы данных: %w", err)  // избыточно
    }
    return nil
}

// Проще:
func (s *Storage) Ping(ctx context.Context) error {
    return s.db.PingContext(ctx)
}

Не используйте panic для обработки ошибок

panic используется только для аварийного завершения, а не механизм обработки ошибок. Он предназначен для ситуаций, когда программа не может продолжать работу: нарушение инварианта, баг в логике, невозможность инициализации критического компонента.

// Допустимо: если конфиг не загрузился, приложение не может работать
func mustLoadConfig(path string) *Config {
    cfg, err := LoadConfig(path)
    if err != nil {
        panic(fmt.Sprintf("загрузка конфигурации: %v", err))
    }
    return cfg
}

// Недопустимо: паника в бизнес-логике
func GetUser(id int64) *User {
    user, err := db.FindUser(id)
    if err != nil {
        panic(err) // Убьёт весь сервер из-за одного запроса
    }
    return user
}

Функции с префиксом Must (такие как regexp.MustCompile или template.Must в стандартной библиотеке) конвенция Go для обозначения функций, которые паникуют при ошибке. Используйте их при инициализации, не в рантайме.

Типичные ошибки

Проверка через == вместо errors.Is. Прямое сравнение ломается, как только кто-то добавляет обёртку через %w. Это может быть вы сами через полгода или коллега, который не знает, что где-то выше по стеку стоит ==.

Игнорирование ошибок. Компилятор Go не заставляет проверять возвращённые ошибки. Линтер errcheck (входит в golangci-lint) ловит непроверенные ошибки, и его стоит включить в CI.

// Тихое игнорирование. Баг, который сложно найти
json.Unmarshal(data, &result)

// Если намеренно игнорируете, то покажите это явно
_ = conn.Close()

Возврат err и ненулевого значения одновременно. Вызывающий код обычно проверяет if err != nil и не использует второе значение. Если вы возвращаете частично заполненную структуру с ошибкой, это приводит к непредсказуемому поведению:

// Плохо: вызывающий может использовать частичный результат
func Parse(data []byte) (*Result, error) {
    r := &Result{Partial: true}
    if err := json.Unmarshal(data, r); err != nil {
        return r, err // Частичный Result с ошибкой
    }
    return r, nil
}

// Хорошо: при ошибке в результате явный nil
func Parse(data []byte) (*Result, error) {
    var r Result
    if err := json.Unmarshal(data, &r); err != nil {
        return nil, fmt.Errorf("парсинг результата: %w", err)
    }
    return &r, nil
}

Строковые проверки ошибок. Подход вида strings.Contains(err.Error(), "not found") ненадёжен. Текст ошибки может измениться в любой момент. Используйте errors.Is и errors.As.

Заключение

Обработка ошибок в Go построена на трёх принципах: ошибки — это значения, каждая ошибка обрабатывается явно, контекст добавляется при передаче вверх по стеку.

На практике основные инструменты — это fmt.Errorf с %w для оборачивания, errors.Is и errors.As для проверки, и errors.Join для объединения нескольких ошибок. Sentinel-ошибки определяют «что случилось», типизированные ошибки несут дополнительные данные.

Частые проблемы, которые я встречаю на код-ревью: ошибки без контекста (голый return err), двойное логирование (залогировали и вернули), и прямое сравнение вместо errors.Is. Все три решаются простыми правилами, которые быстро входят в привычку.

FAQ

Чем %w отличается от %v в fmt.Errorf?

%w оборачивает ошибку, сохраняя связь с оригиналом. errors.Is и errors.As смогут найти оригинальную ошибку в цепочке. %v просто вставляет текст ошибки в строку, чем теряет связь с ошибкой.

original := sql.ErrNoRows

wrapped := fmt.Errorf("запрос: %w", original)
errors.Is(wrapped, sql.ErrNoRows) // true

formatted := fmt.Errorf("запрос: %v", original)
errors.Is(formatted, sql.ErrNoRows) // false

Используйте %w, когда вызывающему коду может понадобиться проверить тип ошибки. Используйте %v, когда хотите скрыть детали реализации (например, чтобы не привязывать пользователей пакета к конкретной ошибке зависимости).

Когда использовать panic, а когда возвращать error?

Возвращайте error практически всегда. panic для исключительных ситуаций, когда продолжение работы невозможно или бессмысленно:

  • Инициализация (если без компонента приложение не может стартовать)
  • Нарушение инварианта, которое указывает на баг (например, default в switch, который «не должен» сработать)
  • Функции вида Must*, вызываемые при старте

В обработчиках HTTP-запросов, в бизнес-логике, в работе с БД используйте только error. Паника в горутине без recover убьёт всю программу.

Стоит ли использовать пакет pkg/errors?

Пакет github.com/pkg/errors был создан до Go 1.13, когда в стандартной библиотеке не было %w, errors.Is и errors.As. Он добавлял оборачивание и stack traces.

С Go 1.13+ стандартная библиотека покрывает основные сценарии. pkg/errors переведён в режим поддержки (maintenance mode), а сам автор рекомендует переходить на стандартные средства. Для новых проектов используйте fmt.Errorf с %w и пакет errors.

Если нужны stack traces, то посмотрите на сторонние решения, но в большинстве случаев контекст, добавленный через %w на каждом уровне, даёт достаточно информации для диагностики.

Как тестировать ошибки?

Через errors.Is проверяем sentinel-ошибки:

func TestGetUser_NotFound(t *testing.T) {
    _, err := storage.GetUser(ctx, 999)
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("ожидали ErrNotFound, получили: %v", err)
    }
}

А через errors.As типизированные:

func TestValidate_InvalidEmail(t *testing.T) {
    err := Validate(User{Email: "invalid"})

    var ve *ValidationError
    if !errors.As(err, &ve) {
        t.Fatalf("ожидали ValidationError, получили: %v", err)
    }
    if ve.Field != "email" {
        t.Errorf("ожидали поле email, получили: %s", ve.Field)
    }
}

Не проверяйте текст ошибки через err.Error(), т.к. он подвержен изменениям. Проверяйте тип и семантику.


Теги: