← назад к разделу

В большинстве языков для ошибок есть исключения: что-то пошло не так — бросаем throw, ловим catch. В Go исключений нет. Ошибка — это обычное значение, которое функция возвращает наравне с результатом. Поначалу это выглядит многословно, но у такого подхода есть важное преимущество: путь каждой ошибки виден прямо в коде.

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

В Go принято возвращать error последним значением. Если всё прошло хорошо — возвращаем nil. Если нет — возвращаем ошибку и нулевое значение для результата.

func findProduct(id int) (Product, error) {
    if id <= 0 {
        return Product{}, errors.New("id должен быть положительным")
    }
    // ...
    return product, nil
}

Вызывающий обязан явно проверить результат:

product, err := findProduct(42)
if err != nil {
    // обрабатываем ошибку
    return
}
// используем product

Проигнорировать ошибку по невнимательности сложнее: если не проверить, компилятор не выдаст ошибку, но статический анализатор (go vet, staticcheck) об этом предупредит. Намеренно проигнорировать можно через _ = ... — это явный сигнал, что вы осознанно пропускаете ошибку.

Оборачивание ошибок: %w и контекст

Когда ошибка поднимается по стеку вызовов, каждый уровень добавляет контекст — где именно что-то пошло не так. Для этого используют fmt.Errorf с глаголом %w:

func (r *ProductRepository) Find(ctx context.Context, id int) (Product, error) {
    row := r.db.QueryRow(ctx, `SELECT id, name FROM products WHERE id = $1`, id)
    var p Product
    if err := row.Scan(&p.ID, &p.Name); err != nil {
        return Product{}, fmt.Errorf("find product %d: %w", id, err)
    }
    return p, nil
}

Благодаря %w исходная ошибка «спрятана» внутри новой, но не потеряна. Если распечатать такую цепочку — получим читаемый путь: "find product 42: no rows in result set".

errors.Is и errors.As: проверка сквозь обёртки

Раз ошибки оборачиваются, простое сравнение err == someErr перестаёт работать — обёртка уже другой объект. Для этого есть две функции:

errors.Is — проверяет, есть ли в цепочке обёрток конкретная ошибка:

if errors.Is(err, pgx.ErrNoRows) {
    // база вернула «нет строк»
}

Это работает даже если err — это fmt.Errorf("...: %w", pgx.ErrNoRows).

errors.As — пытается найти в цепочке ошибку нужного типа и записывает её в переменную. Полезно, когда ошибка несёт данные:

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("поле:", valErr.Field)
}

Sentinel-ошибки: различаем виды отказов

Часто нужно, чтобы вызывающий код мог различить причину ошибки: не найдено, нет доступа, неверный ввод. Для этого объявляют sentinel-ошибки — именованные значения на уровне пакета:

var ErrProductNotFound = errors.New("product not found")
var ErrForbidden = errors.New("forbidden")

В коде репозитория оборачиваем техническую ошибку в доменную:

if errors.Is(err, pgx.ErrNoRows) {
    return Product{}, ErrProductNotFound
}

Теперь вышестоящий код не знает про pgx.ErrNoRows и не зависит от базы данных — он работает только с ErrProductNotFound. Если ошибке нужны данные (например, какой именно id не найден), её делают типом:

type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}

И достают через errors.As.

Перевод доменных ошибок в HTTP-коды

Задача обработчика запроса — перевести доменную ошибку в правильный HTTP-статус. Лучше всего собрать этот перевод в одной функции:

func errorStatus(err error) int {
    switch {
    case errors.Is(err, ErrProductNotFound):
        return http.StatusNotFound
    case errors.Is(err, ErrForbidden):
        return http.StatusForbidden
    case errors.Is(err, ErrBadInput):
        return http.StatusBadRequest
    default:
        return http.StatusInternalServerError
    }
}

Обработчик вызывает её при любой ошибке:

product, err := h.useCase.Execute(r.Context(), cmd)
if err != nil {
    http.Error(w, err.Error(), errorStatus(err))
    return
}

Такой подход хорош тем, что весь перевод «доменное → HTTP» сосредоточен в одном месте. Добавить новый код или изменить маппинг — правка в одной функции.

Panic: когда это уместно

panic в Go — это не аналог исключений. Он предназначен для ситуаций, которых в нормальной работе быть не должно: обращение к nil-указателю, выход за границы среза, программная ошибка, которую невозможно обработать.

Бизнес-ошибки («запись не найдена», «нет прав», «неверный формат») — это всегда error, а не panic.

Чтобы случайная паника в одном запросе не положила весь сервер, в HTTP-промежуточном слое ставят recover:

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Использовать panic/recover для управления потоком программы как замену исключениям — плохая практика в Go.

Коротко

  • В Go ошибка — это возвращаемое значение, путь каждой ошибки виден в коде.
  • fmt.Errorf("...: %w", err) добавляет контекст, сохраняя исходную ошибку.
  • errors.Is проверяет конкретное значение сквозь цепочку обёрток.
  • errors.As извлекает ошибку нужного типа, если она несёт данные.
  • Sentinel-ошибки (var ErrNotFound = errors.New(...)) позволяют различать виды отказов без привязки к технике.
  • Перевод доменных ошибок в HTTP-коды собирают в одной функции-маппере.
  • panic — для невосстановимых ситуаций, не для бизнес-логики; recover в промежуточном слое защищает сервер от падения.

Что почитать дальше

  • Обработчики и JSON в Go — как строить HTTP-обработчики и отдавать JSON-ответы.
  • Промежуточный слой в Go — как подключать recover и другие промежуточные обработчики.
  • Context и отмена — как передавать дедлайны и отменять операции через context.Context.