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

GO КАК ФУНДАМЕНТ Ошибки и HTTP

Обязательно

Ошибки в Go и перевод в HTTP

Go

Возвращаемые ошибки, оборачивание %w, errors.Is и As, sentinel-ошибки и корректные HTTP-статусы.

1. Ошибка возвращается значением

1 / 5

Функция возвращает результат и error. Вызывающий проверяет её сразу и решает, обрабатывать или вернуть выше.

  • Не используйте результат при ненулевой ошибке.
  • Ожидаемый сбой - не panic.
  • Контекст добавляется, а не проглатывается.
value, err := call()err != nil?returnРезультат используют только при nil

2. %w сохраняет причину

2 / 5

fmt.Errorf("load user: %w", err) добавляет место сбоя и оставляет исходную ошибку в цепочке.

  • Текст получает полезный контекст.
  • Исходный тип и значение доступны проверкам.
  • Без %w цепочка обрывается.
repo errorloadUser: %wunwrapКонтекст снаружи, причина внутри

3. Is ищет значение, As извлекает тип

3 / 5

errors.Is проверяет конкретную sentinel-ошибку, errors.As достаёт структуру нужного типа с полями.

  • Не сравнивайте текст ошибки.
  • Is подходит для категории отказа.
  • As нужен для ValidationError и его полей.
errors.IsErrNotFound?errors.As*ValidationErrorwrapped chainПроверка по смыслу, не по строке

4. Sentinel различает виды отказов

4 / 5

ErrNotFound, ErrConflict и ErrValidation позволяют handler выбрать HTTP-статус по смыслу, а не по случайному тексту.

  • NotFound → 404, Conflict → 409, Validation → 400/422.
  • Системная ошибка → 500.
  • Тело ошибки не раскрывает внутренности.
NotFound → 404Conflict → 409Validation → 400JSON errorСтатус по категории ошибки

5. Panic только для нарушенного инварианта

5 / 5

Неверный ввод и внешние сбои возвращаются error. Panic сигналит, что программа не может продолжать корректно.

  • recover перехватывает panic в defer.
  • Логирование выполняется один раз на границе.
  • Формат тела ошибки единый для API.
сбойexpectedinvariantrecovererror для работы, panic для невозможного

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

В большинстве языков для ошибок есть исключения: что-то пошло не так — бросаем 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 этого тоже не ловит — за такими местами следит линтер errcheck (входит в golangci-lint). Намеренно проигнорировать можно через _ = ... — это явный сигнал, что вы осознанно пропускаете ошибку.

Оборачивание ошибок: %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 {
    status := errorStatus(err)
    msg := err.Error()
    if status == http.StatusInternalServerError {
        slog.Error("execute", "err", err) // подробности в лог
        msg = "internal error"            // клиенту без внутренностей
    }
    http.Error(w, msg, status)
    return
}

Такой подход хорош тем, что весь перевод «доменное → HTTP» сосредоточен в одном месте. Добавить новый код или изменить маппинг — правка в одной функции. Текст неожиданной ошибки клиенту не отдают: no rows in result set или имя таблицы из сообщения драйвера — это утечка устройства сервиса и подсказка атакующему; наружу уходит общая фраза, а подробности — в лог.

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

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

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

Случайная panic в обработчике процесс не уронит — net/http перехватывает её сам, но клиент получит обрыв соединения без ответа. Чтобы вместо обрыва уходил 500, а в лог попадал стек, в 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.

Дополнительно: при первом чтении можно пропустить

Глубже: логировать один раз и единый формат теларасширенное

Соблазн записать ошибку в лог на каждом уровне велик: репозиторий залогировал, сервис залогировал, обработчик залогировал. В итоге одна неудача даёт три записи с разным текстом, а по счётчику ошибок кажется, что сервис падает втрое чаще. Правило: нижние слои оборачивают (%w) и возвращают, а логирует один раз тот, кто ошибку окончательно обрабатывает, — обычно обработчик или общий middleware. Обёртки к этому моменту уже собрали весь маршрут в одной строке.

Тело ответа об ошибке тоже стоит зафиксировать один раз для всего сервиса. http.Error отдаёт голый текст, а клиентам удобнее JSON с постоянными полями. Готовый стандарт — RFC 9457 (application/problem+json): type, title, status, detail и при желании свои поля вроде errors со списком полей валидации. Один helper writeProblem(w, status, title, detail) — и все ошибки сервиса выглядят одинаково, включая те, что пришли из Recoverer.

Коротко

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

Что пощупать

В практикуме remodov/marketplace-system-go одна функция fail переводит ошибки в коды: errors.Is с ErrNotFound в 404, errors.As с *OutOfStockError в 409, всё незнакомое в 500 с записью в журнал; тело ответа в формате Problem Details.

Код: handler.go, problem.go.

Сделаем сами

Ветка step-03-commands-and-errors.

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