Перейти к содержанию

Обработка ошибок

Единый стандарт того, как ошибка рождается, прокидывается через слои, превращается в HTTP-ответ и попадает в лог. Смысл правил — чтобы handler всегда знал, как маппить ошибку из service; service всегда знал, как маппить ошибку из repository; а лог при этом писался один раз, на границе.

Логирование ошибок описано в ../conventions/logging.md, HTTP-коды — в ../conventions/http-api.md. Здесь — про саму механику errors.

Содержание

Sentinel-ошибки per слой

В каждом слое держи свой файл errors.go с набором sentinel-значений:

internal/
├── domain/errors.go       — доменные: ErrUserNotFound, ErrReviewAlreadyExists
├── service/errors.go      — бизнес: ErrInvalidCredentials, ErrRateLimit
└── repository/errors.go   — доступ к данным: ErrNotFound, ErrConflict

Объявление — стандартное:

package service

import "errors"

var (
    ErrUserNotFound       = errors.New("service: user not found")
    ErrAlreadyExists      = errors.New("service: already exists")
    ErrInvalidInput       = errors.New("service: invalid input")
    ErrUnauthorized       = errors.New("service: unauthorized")
    ErrForbidden          = errors.New("service: forbidden")
    ErrRateLimit          = errors.New("service: rate limit exceeded")
    ErrConflict           = errors.New("service: conflict")
    ErrInternal           = errors.New("service: internal")
)

Правила:

  • Каждая sentinel-ошибка — с префиксом слоя (service: ..., repository: ...). По префиксу в логах сразу видно, откуда родилась.
  • Одно sentinel-значение — один смысл. Не заводи ErrBadThing, заводи конкретные ErrReviewAlreadyExists, ErrRatingOutOfRange.
  • Sentinel-и объявлены пакетными переменными. Handler сравнивает через errors.Is(err, service.ErrUserNotFound).

Error wrapping

Всегда оборачивай ошибку при возврате вверх через %w:

func (r *UserRepo) Get(ctx context.Context, id int64) (*domain.User, error) {
    var u domain.User
    err := r.pool.QueryRow(ctx, q, id).Scan(&u.ID, &u.Email, &u.Name)
    if err != nil {
        if pkgdb.IsNoRows(err) {
            return nil, repository.ErrNotFound
        }
        return nil, fmt.Errorf("load user by id %d: %w", id, err)
    }
    return &u, nil
}

Контекст — что делал код, а не «что пошло не так». "load user by id %d", а не "query failed": query failed не добавляет ничего поверх самой ошибки.

Не теряй %w: fmt.Errorf("load user: %s", err) превращает ошибку в строку и ломает errors.Is/errors.As. Всегда %w.

Проверка через errors.Is и errors.As

errors.Is — для проверки на конкретное sentinel-значение:

if errors.Is(err, repository.ErrNotFound) {
    return nil, service.ErrUserNotFound
}

errors.As — для извлечения типизированной ошибки с доп. полями:

var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
    writeValidationError(w, verrs)
    return
}

Не используй == для сравнения с sentinel: из-за wrapping это не сработает. Не используй type assertion (err.(*MyErr)) — это не протыкает обёртки; для того же самого существует errors.As.

Custom error types

Заводи собственный тип ошибки только если нужно переносить доп. поля. Если достаточно sentinel — sentinel.

package service

type ValidationError struct {
    Fields []FieldError
}

type FieldError struct {
    Field  string
    Rule   string
    Param  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %d field(s)", len(e.Fields))
}

Если такой тип оборачивает другую ошибку — реализуй Unwrap() error, чтобы errors.Is/errors.As могли пройти сквозь неё.

Слой-границы

Каждый слой возвращает свои sentinel-значения. На границе следующего слоя — преобразуй.

Repository → Service

Repository получает pgx.ErrNoRows → возвращает repository.ErrNotFound:

if pkgdb.IsNoRows(err) {
    return nil, repository.ErrNotFound
}

Service получает repository.ErrNotFound → возвращает доменный service.ErrUserNotFound:

user, err := s.users.Get(ctx, id)
if err != nil {
    if errors.Is(err, repository.ErrNotFound) {
        return nil, service.ErrUserNotFound
    }
    return nil, fmt.Errorf("get user: %w", err)
}

Service → Handler

Handler не знает про repository.*. Он знает только про service.*. Сервисные ошибки превращаются в HTTP через mapServiceError:

func mapServiceError(err error) (status int, code string) {
    switch {
    case errors.Is(err, service.ErrUserNotFound),
         errors.Is(err, service.ErrReviewNotFound):
        return http.StatusNotFound, "not_found"
    case errors.Is(err, service.ErrAlreadyExists):
        return http.StatusConflict, "already_exists"
    case errors.Is(err, service.ErrInvalidInput):
        return http.StatusBadRequest, "invalid_input"
    case errors.Is(err, service.ErrUnauthorized):
        return http.StatusUnauthorized, "unauthorized"
    case errors.Is(err, service.ErrForbidden):
        return http.StatusForbidden, "forbidden"
    case errors.Is(err, service.ErrConflict):
        return http.StatusConflict, "conflict"
    case errors.Is(err, service.ErrRateLimit):
        return http.StatusTooManyRequests, "rate_limited"
    default:
        return http.StatusInternalServerError, "internal"
    }
}

Таблица соответствия:

Sentinel HTTP code
ErrUserNotFound, ErrReviewNotFound 404 not_found
ErrAlreadyExists 409 already_exists
ErrInvalidInput 400 invalid_input
ErrUnauthorized 401 unauthorized
ErrForbidden 403 forbidden
ErrConflict 409 conflict
ErrRateLimit 429 rate_limited
default (unknown) 500 internal

Handler пишет так:

review, err := h.svc.Create(ctx, cmd)
if err != nil {
    status, code := mapServiceError(err)
    if status >= 500 {
        log.FromCtx(ctx).Error("create review", "err", err)
    }
    writeError(w, status, code, clientMessage(code))
    return
}

Panic vs error

Никаких panic в прод-коде. Возврат error — единственный способ сигнализировать о проблеме.

Единственное исключение — unreachable-условие, которое нарушает внутренний инвариант: неинициализированный DI-компонент, switch по enum, где default логически невозможен. В таких местах panic сигнализирует «это баг в коде, а не в данных».

switch channel {
case model.ChannelPush:
    ...
case model.ChannelEmail:
    ...
case model.ChannelSMS:
    ...
case model.ChannelInApp:
    ...
default:
    panic(fmt.Sprintf("unknown channel: %s", channel)) // unreachable
}

Recoverer middleware

HTTP-сервер всегда оборачивается chimw.Recoverer (см. http-api.md). Он ловит panic из handler'а и всех middleware ниже по стеку, логирует с full stack trace и возвращает клиенту 500:

r.Use(chimw.Recoverer)

Watermill router использует middleware.Recoverer — panic в event-handler'е превращается в error, Retry его подхватывает.

Без Recoverer panic в handler'е уронит процесс. Ставь его в обоих местах всегда.

Не скрывай ошибку

Если функция вернула error — ты обязан сделать одно из:

  1. Вернуть вверх с wrap: return fmt.Errorf("...: %w", err) — по умолчанию это.
  2. Обработать: fallback, default, retry. Принятое решение должно быть явным.
  3. Залогировать и продолжать: редкий случай (например, publish в метрики).

Запрещено:

// нельзя
_ = err
_, _ = io.Copy(w, r.Body)
defer tx.Rollback(ctx) // без _ = перед — golangci-lint поймает

_ = err — всегда баг. Либо обрабатывай, либо не вызывай.

Error strings

  • Lowercase: "user not found", не "User not found".
  • Без пунктуации в конце: "user not found", не "user not found.".
  • Без "\n" внутри.
  • Без окончаний типа "... failed" — сама по себе ошибка уже о failure.

Это согласуется с Go code review comments.

Контекст в ошибках

В wrap'е клади достаточно для дебага: идентификаторы (user_id %d, review_id %d, topic %s). Но никакого PII в тексте ошибки. Email маскируй через pkg/pii.MaskEmail (см. logging.md), phone маскируй тем же образом. Токены и пароли в текст ошибки не попадают никогда — даже в замаскированном виде.

// плохо: утекает email
return fmt.Errorf("user %s not found: %w", req.Email, err)

// хорошо
return fmt.Errorf("user not found by email_hash %s: %w",
    pii.MaskEmail(req.Email), err)

errors.Join

Когда собираешь несколько независимых ошибок (например, Close() ресурсов в cleanup) — возвращай их одной через errors.Join:

func (s *Server) Close() error {
    var errs []error
    if err := s.httpServer.Close(); err != nil {
        errs = append(errs, fmt.Errorf("http close: %w", err))
    }
    if err := s.kafkaPub.Close(); err != nil {
        errs = append(errs, fmt.Errorf("kafka close: %w", err))
    }
    if err := s.dbPool.Close(); err != nil {
        errs = append(errs, fmt.Errorf("db close: %w", err))
    }
    return errors.Join(errs...)
}

errors.Is/errors.As работают через join корректно: обе вложенные ошибки будут видны.

Логирование ошибок

Правило: каждая ошибка логируется один раз — на границе, где она перестаёт распространяться. Типично это handler (HTTP-ответ клиенту) или Kafka-consumer (перед отправкой в DLQ).

Не логируй в каждом слое:

// плохо — повторный лог по пути
if err != nil {
    log.Error("repo failed", "err", err) // раз
    return fmt.Errorf("get user: %w", err)
}
// ...
if err != nil {
    log.Error("service failed", "err", err) // два
    return fmt.Errorf("register: %w", err)
}
// handler тоже залогирует — три раза одна ошибка в логах

Пиши лог только в handler'е (или эквивалентной точке-сборщике):

if err != nil {
    status, code := mapServiceError(err)
    if status >= 500 {
        log.FromCtx(ctx).Error("create review", "err", err)
    }
    writeError(w, status, code, clientMessage(code))
    return
}

Для известных ошибок (404, 400, 409) логируй WARN или вообще ничего — это не проблема сервиса, это нормальный поток.

Client-facing messages

Клиент видит generic сообщение + code. Внутренние детали остаются в логах.

{
  "error": {
    "code": "internal",
    "message": "internal server error",
    "request_id": "01HZ3G..."
  }
}

Что можно показывать клиенту:

  • code — машиночитаемый, стабильный.
  • message — человекочитаемая формулировка без подробностей. Для validation: поле + правило. Для conflict: "resource already exists". Для 500: "internal server error" и точка.
  • request_id — чтобы можно было сослаться в support-тикете.

Что нельзя показывать:

  • err.Error() на 500.
  • SQL-запрос, имя таблицы, текст DSN.
  • Stack trace.
  • Внутренние детали типа "failed to connect to redis".

Подробнее про утечки через сообщения — security.md.

Чеклист на ревью

  • Все error'ы прокинуты через %w, не %s/%v.
  • Сравнение с sentinel — через errors.Is, с типом — через errors.As.
  • На границе слоя ошибка пере-оборачивается в sentinel текущего слоя.
  • Panic нет, кроме unreachable-инвариантов.
  • Ошибка логируется один раз — на выходе из сервиса.
  • Клиент получает generic message + code, без деталей из err.Error().
  • В тексте ошибки нет PII.

См. также

  • http-api.md — error mapping в HTTP-коды через writeError / mapServiceError.
  • logging.md — как и на каком уровне логировать ошибку (один раз, на выходе из сервиса).