Обработка ошибок¶
Единый стандарт того, как ошибка рождается, прокидывается через слои, превращается в HTTP-ответ и попадает в лог. Смысл правил — чтобы handler всегда знал, как маппить ошибку из service; service всегда знал, как маппить ошибку из repository; а лог при этом писался один раз, на границе.
Логирование ошибок описано в ../conventions/logging.md,
HTTP-коды — в ../conventions/http-api.md. Здесь — про саму
механику errors.
Содержание¶
- Sentinel-ошибки per слой
- Error wrapping
- Проверка через
errors.Isиerrors.As - Custom error types
- Слой-границы
- Panic vs error
- Recoverer middleware
- Не скрывай ошибку
- Error strings
- Контекст в ошибках
errors.Join- Логирование ошибок
- Client-facing messages
- Чеклист на ревью
- См. также
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-значение:
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:
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:
Watermill router использует middleware.Recoverer — panic в
event-handler'е превращается в error, Retry его подхватывает.
Без Recoverer panic в handler'е уронит процесс. Ставь его в обоих местах всегда.
Не скрывай ошибку¶
Если функция вернула error — ты обязан сделать одно из:
- Вернуть вверх с wrap:
return fmt.Errorf("...: %w", err)— по умолчанию это. - Обработать: fallback, default, retry. Принятое решение должно быть явным.
- Залогировать и продолжать: редкий случай (например, 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. Внутренние детали остаются в
логах.
Что можно показывать клиенту:
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— как и на каком уровне логировать ошибку (один раз, на выходе из сервиса).