Skip to Content
ConventionsОбработка ошибок

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

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

Логирование ошибок описано в ../conventions/logging, HTTP-коды — в ../conventions/http-api. Здесь — про саму механику 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" } }

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

SentinelHTTPcode
ErrUserNotFound, ErrReviewNotFound404not_found
ErrAlreadyExists409already_exists
ErrInvalidInput400invalid_input
ErrUnauthorized401unauthorized
ErrForbidden403forbidden
ErrConflict409conflict
ErrRateLimit429rate_limited
default (unknown)500internal

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). Он ловит 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, phone — через pii.MaskPhone, токены — через pii.MaskToken (полные сигнатуры и реализация — logging.md §PII; пакет живёт в pkg/pii/ сервис-репо, импорт — <module>/pkg/pii). Пароли и refresh-токены в текст ошибки не попадают никогда — даже в замаскированном виде.

import "<module>/pkg/pii" // плохо: утекает email return fmt.Errorf("user %s not found: %w", req.Email, err) // хорошо: `email_masked` — именно маскированная форма (`a***@host`), // не хэш. Для подлинного хеша используй pii.MaskToken и поле // `email_hash`. return fmt.Errorf("user not found by email_masked=%s: %w", pii.MaskEmail(req.Email), err)

Имя поля в error-wrap (email_masked / email_hash) должно отражать реальную трансформацию. email_hash для маски — вводит в заблуждение ревьюера, который будет искать collision- resistant идентификатор.

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.

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

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

См. также

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