В большинстве языков для ошибок есть исключения: что-то пошло не так — бросаем 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.
Что почитать дальше
- Обработчики и JSON в Go — как строить HTTP-обработчики и отдавать JSON-ответы.
- Промежуточный слой в Go — как подключать recover и другие промежуточные обработчики.
- Context и отмена — как передавать дедлайны и отменять операции через context.Context.